From 2085efff00373daaad0758e11611e5d1bab62ec0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 18:56:56 -0400 Subject: [PATCH 01/14] docs: add local inference providers design (Ollama + llama.cpp) Design for two new providers enabling self-hosted model inference with reasoning trace support, step.model_pull for model acquisition from Ollama and HuggingFace, and llama-server process management with auto-download. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...-03-27-local-inference-providers-design.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/plans/2026-03-27-local-inference-providers-design.md diff --git a/docs/plans/2026-03-27-local-inference-providers-design.md b/docs/plans/2026-03-27-local-inference-providers-design.md new file mode 100644 index 0000000..d458c69 --- /dev/null +++ b/docs/plans/2026-03-27-local-inference-providers-design.md @@ -0,0 +1,278 @@ +# Local Inference Providers: Ollama + llama.cpp + +**Date:** 2026-03-27 +**Status:** Approved + +## Goal + +Add local model inference to workflow-plugin-agent via two new providers (Ollama and llama.cpp), enabling self-hosted reasoning models like Qwen3.5 with Claude-style `` traces — no cloud API keys required. + +**Use case split:** +- **Ollama** — developer workstations, local experimentation, quick model swapping +- **llama.cpp** — cloud GPU instances, CI/CD inference, production self-hosted, custom/fine-tuned GGUF models + +## Architecture: Shared Base + Provider Specializations + +```mermaid +graph TD + subgraph "provider package" + L["local.go
ParseThinking, ThinkingStreamParser, LocalAuthMode"] + O["ollama.go
OllamaProvider (ollama/api SDK)"] + C["llama_cpp.go
LlamaCppProvider (openai-go SDK + process mgmt)"] + D["llama_cpp_download.go
EnsureLlamaServer (GitHub release)"] + H["huggingface.go
DownloadFile (generic HF Hub)"] + O --> L + C --> L + end + + subgraph "step types" + MP["step.model_pull
Ollama pull + HuggingFace download"] + AE["step.agent_execute
(existing, unchanged)"] + end + + MP --> O + MP --> H + AE --> O + AE --> C +``` + +## 1. Core Interface Changes + +Add `Thinking` field to `Response` and `StreamEvent` in `provider/provider.go`: + +```go +type Response struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` // reasoning trace + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Usage Usage `json:"usage"` +} + +type StreamEvent struct { + Type string `json:"type"` // "text", "thinking", "tool_call", "done", "error" + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + Tool *ToolCall `json:"tool,omitempty"` + Error string `json:"error,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} +``` + +Existing providers are unaffected — they never populate `Thinking`. + +## 2. Shared Local Helpers (`provider/local.go`) + +**Think tag parsing:** +```go +// ParseThinking extracts ... blocks from model output. +func ParseThinking(raw string) (thinking, content string) +``` + +**Streaming state machine:** +```go +// ThinkingStreamParser tracks state across streaming chunks to split +// thinking vs content tokens. Handles split across chunks. +type ThinkingStreamParser struct { ... } +func (p *ThinkingStreamParser) Feed(chunk string) []StreamEvent +``` + +**Local auth mode helper:** +```go +func LocalAuthMode(name, display string) AuthModeInfo +``` + +## 3. Ollama Provider (`provider/ollama.go`) + +Uses `github.com/ollama/ollama/api` SDK (lightweight client-only package). + +```go +type OllamaConfig struct { + Model string // e.g. "qwen3.5:27b-q4_K_M" + BaseURL string // default: "http://localhost:11434" + MaxTokens int + HTTPClient *http.Client +} +``` + +**Capabilities:** +- `Chat()` / `Stream()` via `api.Client.Chat()` with streaming callback +- `Pull()` — model download via `api.Client.Pull()` with progress +- `ListModels()` — via `api.Client.List()` +- `Health()` — via `api.Client.Heartbeat()` +- Message and tool type conversion: `api.Message` ↔ `provider.Message` +- `ThinkingStreamParser` applied to chat output + +**Module config:** +```yaml +modules: + local-ai: + type: agent.provider + config: + type: ollama + model: "qwen3.5:27b-q4_K_M" +``` + +## 4. llama.cpp Provider (`provider/llama_cpp.go`) + +Connects to OpenAI-compatible server with optional process management. + +```go +type LlamaCppConfig struct { + BaseURL string // external mode if set + ModelPath string // managed mode if set (path to .gguf) + BinaryPath string // override llama-server location + GPULayers int // -ngl flag, default: -1 (all) + ContextSize int // -c flag, default: 8192 + Threads int // -t flag, default: runtime.NumCPU() + Port int // default: 8081 + MaxTokens int + HTTPClient *http.Client +} +``` + +**Two modes:** + +1. **External** (`BaseURL` set): OpenAI SDK pointed at URL. Works with any OpenAI-compatible server (llama-server, vLLM, TGI, etc.) + +2. **Managed** (`ModelPath` set): Provider manages llama-server lifecycle: + - Binary resolution: `BinaryPath` → `PATH` lookup → auto-download from GitHub releases + - Process start: spawn llama-server with configured flags + - Health wait: poll `/health` until ready + - Process stop: kill on close/context cancellation + +**Auto-download** (`provider/llama_cpp_download.go`): +```go +// EnsureLlamaServer finds or downloads the llama-server binary. +// Order: config path → PATH → ~/.cache/workflow/llama-server// +// Downloads from ggerganov/llama.cpp GitHub releases. +func EnsureLlamaServer(ctx context.Context) (string, error) +``` + +Both modes apply `ThinkingStreamParser` to output. + +**Module config examples:** +```yaml +# External server +modules: + local-llm: + type: agent.provider + config: + type: llama_cpp + base_url: "http://localhost:8081/v1" + model: "qwen3.5-27b" + +# Managed process +modules: + local-llm: + type: agent.provider + config: + type: llama_cpp + model: "/models/Qwen3.5-27B-Q4_K_M.gguf" + # gpu_layers: -1 + # context_size: 8192 +``` + +## 5. `step.model_pull` Step Type + +Ensures a model is available before agent execution. Two sources: + +**Ollama:** +```yaml +- name: ensure_model + type: step.model_pull + config: + provider: ollama + model: "qwen3.5:27b-q4_K_M" + base_url: "http://localhost:11434" # optional +``` + +**HuggingFace** (generic — works with any HF repo/file, not GGUF-specific): +```yaml +- name: download_model + type: step.model_pull + config: + provider: huggingface + model: "Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-GGUF" + file: "Qwen3.5-27B-Q4_K_M.gguf" + output_dir: "/models" # default: ~/.cache/workflow/models/ +``` + +**Output:** `{status: "ready"|"downloaded"|"error", model_path: string, size_bytes: int}` + +**HuggingFace downloader** (`provider/huggingface.go`): +```go +// DownloadFile downloads any file from a HuggingFace model repo. +// Uses HF Hub REST API with resume support (Range header). +func DownloadFile(ctx context.Context, repo, filename, outputDir string, progress func(pct float64)) (string, error) +``` + +## 6. Executor & Transcript Changes + +**Executor** (`executor/executor.go`): +- When `resp.Thinking != ""`, record transcript entry with type `"thinking"` +- Include `thinking` in step output: `{result, thinking, status, iterations, error}` +- Forward `"thinking"` stream events to SSE listeners + +**Step output** (`step_agent_execute.go`): +- Add `output["thinking"] = resp.Thinking` +- Accessible as `{{ index .steps "agent" "thinking" }}` + +**No changes to:** Provider interface (beyond Section 1), tool registry, approval gates, context compaction. + +## 7. Registry & Plugin Wiring + +**provider_registry.go** — add `"ollama"` and `"llama_cpp"` factories +**module_provider.go** — add cases for both types +**plugin.go** — register `step.model_pull`, update module schemas with new provider types +**auth_modes.go** — add `ollama` and `llama_cpp` local auth modes +**models.go** — add model listing for both providers + +## 8. Testing Strategy + +All tests use HTTP mocks — no live GPU or model downloads in CI. + +- `ThinkingStreamParser` — chunked input, split tags, nested tags, unclosed tags +- `ParseThinking` — simple extraction, no-think content, multiple blocks +- Ollama provider — mock HTTP server returning Ollama API responses +- llama.cpp provider — mock OpenAI-compatible server, managed mode lifecycle +- HuggingFace download — mock server with Range request support, resume +- `step.model_pull` — integration with mock endpoints +- Executor thinking — verify thinking in transcript entries and step output + +## 9. File Summary + +``` +provider/ + provider.go MODIFY add Thinking to Response/StreamEvent + local.go NEW ParseThinking, ThinkingStreamParser, LocalAuthMode + local_test.go NEW think parser tests + ollama.go NEW OllamaProvider + ollama_convert.go NEW api.Message ↔ provider.Message + ollama_test.go NEW unit tests + llama_cpp.go NEW LlamaCppProvider + llama_cpp_download.go NEW EnsureLlamaServer + llama_cpp_test.go NEW unit tests + huggingface.go NEW DownloadFile (generic HF Hub) + huggingface_test.go NEW unit tests + auth_modes.go MODIFY add local auth modes + models.go MODIFY add local model listing + +provider_registry.go MODIFY add ollama + llama_cpp factories +module_provider.go MODIFY add ollama/llama_cpp cases +step_model_pull.go NEW step.model_pull +step_model_pull_test.go NEW tests +plugin.go MODIFY register step.model_pull, update schemas + +executor/ + executor.go MODIFY thread thinking into transcript + output +``` + +## 10. Dependencies + +**New:** +- `github.com/ollama/ollama/api` — Ollama client (lightweight, client-only) + +**Existing (reused):** +- `github.com/openai/openai-go` — for llama.cpp OpenAI-compatible protocol +- `net/http` — HuggingFace downloads, llama-server health checks +- `os/exec` — managed llama-server process From e4e3a3da503a233a077c71574c0ebb3d13afb456 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:02:35 -0400 Subject: [PATCH 02/14] docs: add local inference providers implementation plan 12-task TDD plan covering: core interface changes, think-tag parser, Ollama provider (SDK), llama.cpp provider (process mgmt + auto-download), HuggingFace downloader, step.model_pull, executor thinking threading, and registry/plugin wiring. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...26-03-27-local-inference-providers-plan.md | 2732 +++++++++++++++++ 1 file changed, 2732 insertions(+) create mode 100644 docs/plans/2026-03-27-local-inference-providers-plan.md diff --git a/docs/plans/2026-03-27-local-inference-providers-plan.md b/docs/plans/2026-03-27-local-inference-providers-plan.md new file mode 100644 index 0000000..f9eb279 --- /dev/null +++ b/docs/plans/2026-03-27-local-inference-providers-plan.md @@ -0,0 +1,2732 @@ +# Local Inference Providers Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Ollama and llama.cpp providers to workflow-plugin-agent with `` reasoning trace support and a `step.model_pull` step for model acquisition. + +**Architecture:** Shared thinking-trace parser in `provider/local.go`, Ollama provider via `ollama/ollama/api` SDK, llama.cpp provider via existing `openai-go` SDK with optional process management, HuggingFace downloader for GGUF files. All changes in `workflow-plugin-agent` repo at `/Users/jon/workspace/workflow-plugin-agent`. + +**Tech Stack:** Go 1.26, `github.com/ollama/ollama/api`, `github.com/openai/openai-go` (existing), `os/exec` for llama-server process management. + +--- + +### Task 1: Core Interface — Add Thinking Fields to Response and StreamEvent + +**Files:** +- Modify: `provider/provider.go:39-58` + +**Step 1: Write the failing test** + +Create `provider/thinking_field_test.go`: + +```go +package provider + +import ( + "encoding/json" + "testing" +) + +func TestResponse_ThinkingFieldJSON(t *testing.T) { + r := Response{ + Content: "Hello", + Thinking: "I should greet the user", + Usage: Usage{InputTokens: 10, OutputTokens: 5}, + } + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var decoded Response + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Thinking != "I should greet the user" { + t.Errorf("Thinking: want %q, got %q", "I should greet the user", decoded.Thinking) + } +} + +func TestResponse_ThinkingOmittedWhenEmpty(t *testing.T) { + r := Response{Content: "Hello"} + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + s := string(b) + if contains(s, "thinking") { + t.Errorf("expected thinking to be omitted from JSON when empty, got: %s", s) + } +} + +func TestStreamEvent_ThinkingType(t *testing.T) { + e := StreamEvent{Type: "thinking", Thinking: "reasoning here"} + b, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + var decoded StreamEvent + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Type != "thinking" { + t.Errorf("Type: want thinking, got %q", decoded.Type) + } + if decoded.Thinking != "reasoning here" { + t.Errorf("Thinking: want %q, got %q", "reasoning here", decoded.Thinking) + } +} + +func contains(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run TestResponse_Thinking -v` +Expected: FAIL — `Response` has no `Thinking` field + +**Step 3: Add Thinking fields** + +In `provider/provider.go`, add `Thinking` to `Response` (line 39-43): + +```go +type Response struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Usage Usage `json:"usage"` +} +``` + +Add `Thinking` to `StreamEvent` (line 52-58): + +```go +type StreamEvent struct { + Type string `json:"type"` // "text", "thinking", "tool_call", "done", "error" + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + Tool *ToolCall `json:"tool,omitempty"` + Error string `json:"error,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run TestResponse_Thinking -v && go test ./provider/ -run TestStreamEvent_Thinking -v` +Expected: PASS + +**Step 5: Run all existing tests to verify no regressions** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./...` +Expected: All existing tests still pass (field additions are backward-compatible) + +**Step 6: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider/provider.go provider/thinking_field_test.go +git commit -m "feat: add Thinking field to Response and StreamEvent types" +``` + +--- + +### Task 2: Shared Local Helpers — Think Tag Parser + +**Files:** +- Create: `provider/local.go` +- Create: `provider/local_test.go` + +**Step 1: Write the failing tests** + +Create `provider/local_test.go`: + +```go +package provider + +import "testing" + +func TestParseThinking_BasicExtraction(t *testing.T) { + input := "Let me reason about this.The answer is 42." + thinking, content := ParseThinking(input) + if thinking != "Let me reason about this." { + t.Errorf("thinking: want %q, got %q", "Let me reason about this.", thinking) + } + if content != "The answer is 42." { + t.Errorf("content: want %q, got %q", "The answer is 42.", content) + } +} + +func TestParseThinking_NoThinkTags(t *testing.T) { + input := "Just a normal response." + thinking, content := ParseThinking(input) + if thinking != "" { + t.Errorf("thinking: want empty, got %q", thinking) + } + if content != "Just a normal response." { + t.Errorf("content: want %q, got %q", "Just a normal response.", content) + } +} + +func TestParseThinking_MultipleBlocks(t *testing.T) { + input := "First thought.Middle text.Second thought.Final answer." + thinking, content := ParseThinking(input) + if thinking != "First thought.\nSecond thought." { + t.Errorf("thinking: want %q, got %q", "First thought.\nSecond thought.", thinking) + } + if content != "Middle text.Final answer." { + t.Errorf("content: want %q, got %q", "Middle text.Final answer.", content) + } +} + +func TestParseThinking_UnclosedTag(t *testing.T) { + input := "Unclosed reasoning" + thinking, content := ParseThinking(input) + if thinking != "Unclosed reasoning" { + t.Errorf("thinking: want %q, got %q", "Unclosed reasoning", thinking) + } + if content != "" { + t.Errorf("content: want empty, got %q", content) + } +} + +func TestParseThinking_EmptyThinkBlock(t *testing.T) { + input := "Content here." + thinking, content := ParseThinking(input) + if thinking != "" { + t.Errorf("thinking: want empty, got %q", thinking) + } + if content != "Content here." { + t.Errorf("content: want %q, got %q", "Content here.", content) + } +} + +func TestParseThinking_WhitespaceAroundTags(t *testing.T) { + input := " reasoning here answer here " + thinking, content := ParseThinking(input) + if thinking != " reasoning here " { + t.Errorf("thinking: want %q, got %q", " reasoning here ", thinking) + } + if content != " answer here " { + t.Errorf("content: want %q, got %q", " answer here ", content) + } +} + +func TestThinkingStreamParser_BasicFlow(t *testing.T) { + p := &ThinkingStreamParser{} + + // Feed opening think tag + events := p.Feed("") + if len(events) != 0 { + t.Errorf("expected 0 events for opening tag, got %d", len(events)) + } + + // Feed thinking content + events = p.Feed("reasoning here") + if len(events) != 1 || events[0].Type != "thinking" || events[0].Thinking != "reasoning here" { + t.Errorf("expected thinking event, got %+v", events) + } + + // Feed closing tag + events = p.Feed("") + if len(events) != 0 { + t.Errorf("expected 0 events for closing tag, got %d", len(events)) + } + + // Feed normal content + events = p.Feed("The answer is 42.") + if len(events) != 1 || events[0].Type != "text" || events[0].Text != "The answer is 42." { + t.Errorf("expected text event, got %+v", events) + } +} + +func TestThinkingStreamParser_TagSplitAcrossChunks(t *testing.T) { + p := &ThinkingStreamParser{} + + // Opening tag split: "" + events := p.Feed("inner") + if len(events) != 1 || events[0].Type != "thinking" || events[0].Thinking != "inner" { + t.Errorf("expected thinking event with 'inner', got %+v", events) + } +} + +func TestThinkingStreamParser_NoThinkTags(t *testing.T) { + p := &ThinkingStreamParser{} + + events := p.Feed("Just normal text.") + if len(events) != 1 || events[0].Type != "text" || events[0].Text != "Just normal text." { + t.Errorf("expected text event, got %+v", events) + } +} + +func TestLocalAuthMode(t *testing.T) { + info := LocalAuthMode("ollama", "Ollama (Local)") + if info.Mode != "local" { + t.Errorf("Mode: want local, got %q", info.Mode) + } + if info.DisplayName != "Ollama (Local)" { + t.Errorf("DisplayName: want %q, got %q", "Ollama (Local)", info.DisplayName) + } + if !info.ServerSafe { + t.Error("expected ServerSafe to be true") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestParseThinking|TestThinkingStream|TestLocalAuth" -v` +Expected: FAIL — functions don't exist + +**Step 3: Implement local.go** + +Create `provider/local.go`: + +```go +package provider + +import "strings" + +// ParseThinking extracts ... blocks from model output. +// Returns (thinking, content) where thinking is all extracted reasoning +// joined by newlines, and content has all think blocks removed. +func ParseThinking(raw string) (thinking, content string) { + var thinkParts []string + var contentBuf strings.Builder + remaining := raw + + for { + openIdx := strings.Index(remaining, "") + if openIdx == -1 { + contentBuf.WriteString(remaining) + break + } + contentBuf.WriteString(remaining[:openIdx]) + remaining = remaining[openIdx+len(""):] + + closeIdx := strings.Index(remaining, "") + if closeIdx == -1 { + // Unclosed tag — rest is thinking + if remaining != "" { + thinkParts = append(thinkParts, remaining) + } + break + } + block := remaining[:closeIdx] + if block != "" { + thinkParts = append(thinkParts, block) + } + remaining = remaining[closeIdx+len(""):] + } + + return strings.Join(thinkParts, "\n"), contentBuf.String() +} + +// ThinkingStreamParser tracks state across streaming chunks to correctly +// split thinking vs content tokens. It handles and tags +// that may be split across multiple chunks. +type ThinkingStreamParser struct { + inThink bool + buf strings.Builder // partial tag buffer +} + +// Feed processes a chunk of text and returns stream events. +// Events are either type "thinking" or type "text". +func (p *ThinkingStreamParser) Feed(chunk string) []StreamEvent { + p.buf.WriteString(chunk) + text := p.buf.String() + p.buf.Reset() + + var events []StreamEvent + + for len(text) > 0 { + if p.inThink { + closeIdx := strings.Index(text, "") + if closeIdx == -1 { + // Check for partial closing tag at end + for i := 1; i < len("") && i <= len(text); i++ { + suffix := text[len(text)-i:] + if strings.HasPrefix("", suffix) { + thinkContent := text[:len(text)-i] + if thinkContent != "" { + events = append(events, StreamEvent{Type: "thinking", Thinking: thinkContent}) + } + p.buf.WriteString(suffix) + return events + } + } + // No partial tag — all thinking + events = append(events, StreamEvent{Type: "thinking", Thinking: text}) + return events + } + thinkContent := text[:closeIdx] + if thinkContent != "" { + events = append(events, StreamEvent{Type: "thinking", Thinking: thinkContent}) + } + p.inThink = false + text = text[closeIdx+len("
"):] + } else { + openIdx := strings.Index(text, "") + if openIdx == -1 { + // Check for partial opening tag at end + for i := 1; i < len("") && i <= len(text); i++ { + suffix := text[len(text)-i:] + if strings.HasPrefix("", suffix) { + normalContent := text[:len(text)-i] + if normalContent != "" { + events = append(events, StreamEvent{Type: "text", Text: normalContent}) + } + p.buf.WriteString(suffix) + return events + } + } + // No partial tag — all content + events = append(events, StreamEvent{Type: "text", Text: text}) + return events + } + if openIdx > 0 { + events = append(events, StreamEvent{Type: "text", Text: text[:openIdx]}) + } + p.inThink = true + text = text[openIdx+len(""):] + } + } + + return events +} + +// LocalAuthMode returns an AuthModeInfo for local model providers. +func LocalAuthMode(name, displayName string) AuthModeInfo { + return AuthModeInfo{ + Mode: "local", + DisplayName: displayName, + Description: "Local model inference, no API key required.", + ServerSafe: true, + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestParseThinking|TestThinkingStream|TestLocalAuth" -v` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider/local.go provider/local_test.go +git commit -m "feat: add shared think-tag parser and streaming state machine" +``` + +--- + +### Task 3: Add Ollama Dependency + +**Files:** +- Modify: `go.mod` + +**Step 1: Add the ollama/api dependency** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go get github.com/ollama/ollama/api` + +**Step 2: Tidy** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go mod tidy` + +**Step 3: Verify build** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go build ./...` +Expected: PASS + +**Step 4: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add go.mod go.sum +git commit -m "chore: add github.com/ollama/ollama/api dependency" +``` + +--- + +### Task 4: Ollama Provider — Type Conversion + +**Files:** +- Create: `provider/ollama_convert.go` +- Create: `provider/ollama_convert_test.go` + +**Step 1: Write the failing tests** + +Create `provider/ollama_convert_test.go`: + +```go +package provider + +import ( + "testing" + + ollamaapi "github.com/ollama/ollama/api" +) + +func TestToOllamaMessages_BasicRoles(t *testing.T) { + msgs := []Message{ + {Role: RoleSystem, Content: "You are helpful."}, + {Role: RoleUser, Content: "Hello"}, + {Role: RoleAssistant, Content: "Hi there!"}, + } + result := toOllamaMessages(msgs) + if len(result) != 3 { + t.Fatalf("want 3 messages, got %d", len(result)) + } + if result[0].Role != "system" || result[0].Content != "You are helpful." { + t.Errorf("msg[0]: want system/You are helpful., got %s/%s", result[0].Role, result[0].Content) + } + if result[1].Role != "user" || result[1].Content != "Hello" { + t.Errorf("msg[1]: want user/Hello, got %s/%s", result[1].Role, result[1].Content) + } + if result[2].Role != "assistant" || result[2].Content != "Hi there!" { + t.Errorf("msg[2]: want assistant/Hi there!, got %s/%s", result[2].Role, result[2].Content) + } +} + +func TestToOllamaMessages_ToolResult(t *testing.T) { + msgs := []Message{ + {Role: RoleTool, Content: `{"result":"ok"}`, ToolCallID: "call-1"}, + } + result := toOllamaMessages(msgs) + if len(result) != 1 { + t.Fatalf("want 1 message, got %d", len(result)) + } + if result[0].Role != "tool" { + t.Errorf("role: want tool, got %s", result[0].Role) + } +} + +func TestToOllamaMessages_AssistantWithToolCalls(t *testing.T) { + msgs := []Message{ + { + Role: RoleAssistant, + ToolCalls: []ToolCall{ + {ID: "tc-1", Name: "search", Arguments: map[string]any{"query": "test"}}, + }, + }, + } + result := toOllamaMessages(msgs) + if len(result) != 1 { + t.Fatalf("want 1 message, got %d", len(result)) + } + if len(result[0].ToolCalls) != 1 { + t.Fatalf("want 1 tool call, got %d", len(result[0].ToolCalls)) + } + if result[0].ToolCalls[0].Function.Name != "search" { + t.Errorf("tool name: want search, got %s", result[0].ToolCalls[0].Function.Name) + } +} + +func TestToOllamaTools(t *testing.T) { + tools := []ToolDef{ + { + Name: "get_weather", + Description: "Get weather for a location", + Parameters: map[string]any{"type": "object", "properties": map[string]any{"location": map[string]any{"type": "string"}}}, + }, + } + result := toOllamaTools(tools) + if len(result) != 1 { + t.Fatalf("want 1 tool, got %d", len(result)) + } + if result[0].Function.Name != "get_weather" { + t.Errorf("name: want get_weather, got %s", result[0].Function.Name) + } + if result[0].Function.Description != "Get weather for a location" { + t.Errorf("description: want %q, got %q", "Get weather for a location", result[0].Function.Description) + } +} + +func TestFromOllamaResponse_Content(t *testing.T) { + ollamaResp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{ + Role: "assistant", + Content: "I should help.The answer is 42.", + }, + } + resp := fromOllamaResponse(ollamaResp) + if resp.Content != "The answer is 42." { + t.Errorf("Content: want %q, got %q", "The answer is 42.", resp.Content) + } + if resp.Thinking != "I should help." { + t.Errorf("Thinking: want %q, got %q", "I should help.", resp.Thinking) + } +} + +func TestFromOllamaResponse_ToolCalls(t *testing.T) { + ollamaResp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{ + Role: "assistant", + ToolCalls: []ollamaapi.ToolCall{ + { + Function: ollamaapi.ToolCallFunction{ + Name: "search", + Arguments: ollamaapi.ToolCallFunctionArguments{"query": "test"}, + }, + }, + }, + }, + } + resp := fromOllamaResponse(ollamaResp) + if len(resp.ToolCalls) != 1 { + t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].Name != "search" { + t.Errorf("tool name: want search, got %s", resp.ToolCalls[0].Name) + } + q, ok := resp.ToolCalls[0].Arguments["query"] + if !ok || q != "test" { + t.Errorf("tool args: want query=test, got %v", resp.ToolCalls[0].Arguments) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestToOllama|TestFromOllama" -v` +Expected: FAIL — functions don't exist + +**Step 3: Implement ollama_convert.go** + +Create `provider/ollama_convert.go`: + +```go +package provider + +import ( + "encoding/json" + "fmt" + + ollamaapi "github.com/ollama/ollama/api" +) + +// toOllamaMessages converts provider messages to Ollama API messages. +func toOllamaMessages(msgs []Message) []ollamaapi.Message { + result := make([]ollamaapi.Message, 0, len(msgs)) + for _, msg := range msgs { + om := ollamaapi.Message{ + Role: string(msg.Role), + Content: msg.Content, + } + for _, tc := range msg.ToolCalls { + args := ollamaapi.ToolCallFunctionArguments{} + for k, v := range tc.Arguments { + args[k] = v + } + om.ToolCalls = append(om.ToolCalls, ollamaapi.ToolCall{ + Function: ollamaapi.ToolCallFunction{ + Name: tc.Name, + Arguments: args, + }, + }) + } + result = append(result, om) + } + return result +} + +// toOllamaTools converts provider tool definitions to Ollama API tools. +func toOllamaTools(tools []ToolDef) []ollamaapi.Tool { + result := make([]ollamaapi.Tool, 0, len(tools)) + for _, t := range tools { + params := ollamaapi.ToolFunctionParameters{ + Type: "object", + } + if t.Parameters != nil { + if props, ok := t.Parameters["properties"]; ok { + if pm, ok := props.(map[string]any); ok { + propMap := make(ollamaapi.ToolPropertiesMap) + for k, v := range pm { + prop := ollamaapi.ToolProperty{} + if vm, ok := v.(map[string]any); ok { + if pt, ok := vm["type"].(string); ok { + prop.Type = ollamaapi.PropertyType(pt) + } + if desc, ok := vm["description"].(string); ok { + prop.Description = desc + } + if enum, ok := vm["enum"].([]any); ok { + for _, e := range enum { + if s, ok := e.(string); ok { + prop.Enum = append(prop.Enum, s) + } + } + } + } + propMap[k] = prop + } + params.Properties = propMap + } + } + if req, ok := t.Parameters["required"]; ok { + if reqSlice, ok := req.([]any); ok { + for _, r := range reqSlice { + if s, ok := r.(string); ok { + params.Required = append(params.Required, s) + } + } + } + } + } + result = append(result, ollamaapi.Tool{ + Type: "function", + Function: ollamaapi.ToolFunction{ + Name: t.Name, + Description: t.Description, + Parameters: params, + }, + }) + } + return result +} + +// fromOllamaResponse converts an Ollama ChatResponse to the provider Response type. +// It applies ParseThinking to extract blocks. +func fromOllamaResponse(resp ollamaapi.ChatResponse) *Response { + thinking, content := ParseThinking(resp.Message.Content) + r := &Response{ + Content: content, + Thinking: thinking, + } + + for _, tc := range resp.Message.ToolCalls { + args := make(map[string]any) + for k, v := range tc.Function.Arguments { + args[k] = v + } + // Generate a tool call ID since Ollama doesn't provide one + r.ToolCalls = append(r.ToolCalls, ToolCall{ + ID: fmt.Sprintf("ollama-%s-%d", tc.Function.Name, len(r.ToolCalls)), + Name: tc.Function.Name, + Arguments: args, + }) + } + + // Map Ollama metrics to usage if available + if resp.PromptEvalCount > 0 || resp.EvalCount > 0 { + r.Usage = Usage{ + InputTokens: resp.PromptEvalCount, + OutputTokens: resp.EvalCount, + } + } + + return r +} + +// fromOllamaStreamChunk converts an Ollama streaming ChatResponse to a string chunk. +// The caller is responsible for feeding this through ThinkingStreamParser. +func fromOllamaStreamChunk(resp ollamaapi.ChatResponse) (string, []ToolCall, bool) { + var toolCalls []ToolCall + for _, tc := range resp.Message.ToolCalls { + args := make(map[string]any) + for k, v := range tc.Function.Arguments { + args[k] = v + } + toolCalls = append(toolCalls, ToolCall{ + ID: fmt.Sprintf("ollama-%s-%d", tc.Function.Name, len(toolCalls)), + Name: tc.Function.Name, + Arguments: args, + }) + } + return resp.Message.Content, toolCalls, resp.Done +} + +// ollamaToolCallsToJSON converts tool calls for embedding in Ollama messages. +func ollamaToolCallsToJSON(tcs []ToolCall) string { + b, _ := json.Marshal(tcs) + return string(b) +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestToOllama|TestFromOllama" -v` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider/ollama_convert.go provider/ollama_convert_test.go +git commit -m "feat: add Ollama message and tool type conversion helpers" +``` + +--- + +### Task 5: Ollama Provider — Core Implementation + +**Files:** +- Create: `provider/ollama.go` +- Create: `provider/ollama_test.go` + +**Step 1: Write the failing tests** + +Create `provider/ollama_test.go`: + +```go +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOllamaProvider_Name(t *testing.T) { + p := NewOllamaProvider(OllamaConfig{Model: "test"}) + if p.Name() != "ollama" { + t.Errorf("Name: want ollama, got %q", p.Name()) + } +} + +func TestOllamaProvider_AuthModeInfo(t *testing.T) { + p := NewOllamaProvider(OllamaConfig{Model: "test"}) + info := p.AuthModeInfo() + if info.Mode != "local" { + t.Errorf("Mode: want local, got %q", info.Mode) + } + if !info.ServerSafe { + t.Error("expected ServerSafe to be true") + } +} + +func TestOllamaProvider_Chat(t *testing.T) { + // Mock Ollama API server + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/chat" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + + resp := map[string]any{ + "model": "test-model", + "message": map[string]any{"role": "assistant", "content": "Reasoning.The answer."}, + "done": true, + "prompt_eval_count": 10, + "eval_count": 5, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{ + Model: "test-model", + BaseURL: srv.URL, + }) + + resp, err := p.Chat(context.Background(), []Message{ + {Role: RoleUser, Content: "What is 6*7?"}, + }, nil) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "The answer." { + t.Errorf("Content: want %q, got %q", "The answer.", resp.Content) + } + if resp.Thinking != "Reasoning." { + t.Errorf("Thinking: want %q, got %q", "Reasoning.", resp.Thinking) + } +} + +func TestOllamaProvider_Health(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + w.WriteHeader(http.StatusOK) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{ + Model: "test", + BaseURL: srv.URL, + }) + + if err := p.Health(context.Background()); err != nil { + t.Fatalf("Health: %v", err) + } +} + +func TestOllamaProvider_ListModels(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/tags" { + resp := map[string]any{ + "models": []map[string]any{ + {"name": "llama3:latest", "size": 4000000000}, + {"name": "qwen3.5:27b", "size": 16000000000}, + }, + } + json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{ + Model: "test", + BaseURL: srv.URL, + }) + + models, err := p.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + if len(models) != 2 { + t.Fatalf("want 2 models, got %d", len(models)) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestOllamaProvider" -v` +Expected: FAIL — `NewOllamaProvider` doesn't exist + +**Step 3: Implement ollama.go** + +Create `provider/ollama.go`: + +```go +package provider + +import ( + "context" + "fmt" + "net/http" + "net/url" + + ollamaapi "github.com/ollama/ollama/api" +) + +const ( + defaultOllamaBaseURL = "http://localhost:11434" + defaultOllamaModel = "llama3" +) + +// OllamaConfig holds configuration for the Ollama provider. +type OllamaConfig struct { + Model string + BaseURL string + MaxTokens int + HTTPClient *http.Client +} + +// OllamaProvider implements Provider using the Ollama API. +type OllamaProvider struct { + client *ollamaapi.Client + config OllamaConfig +} + +// NewOllamaProvider creates a new Ollama provider. +func NewOllamaProvider(cfg OllamaConfig) *OllamaProvider { + if cfg.Model == "" { + cfg.Model = defaultOllamaModel + } + if cfg.BaseURL == "" { + cfg.BaseURL = defaultOllamaBaseURL + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = http.DefaultClient + } + + base, _ := url.Parse(cfg.BaseURL) + client := ollamaapi.NewClient(base, cfg.HTTPClient) + + return &OllamaProvider{client: client, config: cfg} +} + +func (p *OllamaProvider) Name() string { return "ollama" } + +func (p *OllamaProvider) AuthModeInfo() AuthModeInfo { + return LocalAuthMode("ollama", "Ollama (Local)") +} + +func (p *OllamaProvider) Chat(ctx context.Context, messages []Message, tools []ToolDef) (*Response, error) { + req := &ollamaapi.ChatRequest{ + Model: p.config.Model, + Messages: toOllamaMessages(messages), + Stream: new(bool), // false — non-streaming + } + if len(tools) > 0 { + req.Tools = toOllamaTools(tools) + } + if p.config.MaxTokens > 0 { + req.Options = map[string]any{"num_predict": p.config.MaxTokens} + } + + var chatResp ollamaapi.ChatResponse + err := p.client.Chat(ctx, req, func(resp ollamaapi.ChatResponse) error { + chatResp = resp + return nil + }) + if err != nil { + return nil, fmt.Errorf("ollama: %w", err) + } + + return fromOllamaResponse(chatResp), nil +} + +func (p *OllamaProvider) Stream(ctx context.Context, messages []Message, tools []ToolDef) (<-chan StreamEvent, error) { + stream := new(bool) + *stream = true + + req := &ollamaapi.ChatRequest{ + Model: p.config.Model, + Messages: toOllamaMessages(messages), + Stream: stream, + } + if len(tools) > 0 { + req.Tools = toOllamaTools(tools) + } + if p.config.MaxTokens > 0 { + req.Options = map[string]any{"num_predict": p.config.MaxTokens} + } + + ch := make(chan StreamEvent, 16) + go func() { + defer close(ch) + parser := &ThinkingStreamParser{} + var usage *Usage + + err := p.client.Chat(ctx, req, func(resp ollamaapi.ChatResponse) error { + chunk, toolCalls, done := fromOllamaStreamChunk(resp) + + // Track usage from final chunk + if resp.PromptEvalCount > 0 || resp.EvalCount > 0 { + usage = &Usage{ + InputTokens: resp.PromptEvalCount, + OutputTokens: resp.EvalCount, + } + } + + // Emit tool calls + for i := range toolCalls { + ch <- StreamEvent{Type: "tool_call", Tool: &toolCalls[i]} + } + + // Feed text through thinking parser + if chunk != "" { + for _, evt := range parser.Feed(chunk) { + ch <- evt + } + } + + if done { + ch <- StreamEvent{Type: "done", Usage: usage} + } + + return nil + }) + if err != nil { + ch <- StreamEvent{Type: "error", Error: err.Error()} + } + }() + + return ch, nil +} + +// Pull downloads a model via the Ollama API. +func (p *OllamaProvider) Pull(ctx context.Context, model string, progress func(status string, pct float64)) error { + req := &ollamaapi.PullRequest{Model: model} + return p.client.Pull(ctx, req, func(resp ollamaapi.ProgressResponse) error { + if progress != nil { + pct := 0.0 + if resp.Total > 0 { + pct = float64(resp.Completed) / float64(resp.Total) * 100 + } + progress(resp.Status, pct) + } + return nil + }) +} + +// ListModels returns locally available models from the Ollama server. +func (p *OllamaProvider) ListModels(ctx context.Context) ([]ModelInfo, error) { + resp, err := p.client.List(ctx) + if err != nil { + return nil, fmt.Errorf("ollama: list models: %w", err) + } + + models := make([]ModelInfo, 0, len(resp.Models)) + for _, m := range resp.Models { + models = append(models, ModelInfo{ + ID: m.Name, + Name: m.Name, + }) + } + return models, nil +} + +// Health checks if the Ollama server is reachable. +func (p *OllamaProvider) Health(ctx context.Context) error { + return p.client.Heartbeat(ctx) +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestOllamaProvider" -v` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider/ollama.go provider/ollama_test.go +git commit -m "feat: add Ollama provider with chat, stream, pull, and health" +``` + +--- + +### Task 6: llama.cpp Provider — Core Implementation + +**Files:** +- Create: `provider/llama_cpp.go` +- Create: `provider/llama_cpp_test.go` + +**Step 1: Write the failing tests** + +Create `provider/llama_cpp_test.go`: + +```go +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestLlamaCppProvider_Name(t *testing.T) { + p := NewLlamaCppProvider(LlamaCppConfig{BaseURL: "http://localhost:8081/v1"}) + if p.Name() != "llama_cpp" { + t.Errorf("Name: want llama_cpp, got %q", p.Name()) + } +} + +func TestLlamaCppProvider_AuthModeInfo(t *testing.T) { + p := NewLlamaCppProvider(LlamaCppConfig{BaseURL: "http://localhost:8081/v1"}) + info := p.AuthModeInfo() + if info.Mode != "local" { + t.Errorf("Mode: want local, got %q", info.Mode) + } +} + +func TestLlamaCppProvider_Chat_ExternalMode(t *testing.T) { + // Mock OpenAI-compatible server + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Errorf("unexpected path: %s", r.URL.Path) + http.NotFound(w, r) + return + } + resp := map[string]any{ + "id": "chatcmpl-123", + "object": "chat.completion", + "model": "local-model", + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{"role": "assistant", "content": "Let me think.Result here."}, + "finish_reason": "stop", + }, + }, + "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 8}, + } + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + p := NewLlamaCppProvider(LlamaCppConfig{ + BaseURL: srv.URL + "/v1", + }) + + resp, err := p.Chat(context.Background(), []Message{ + {Role: RoleUser, Content: "Hello"}, + }, nil) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "Result here." { + t.Errorf("Content: want %q, got %q", "Result here.", resp.Content) + } + if resp.Thinking != "Let me think." { + t.Errorf("Thinking: want %q, got %q", "Let me think.", resp.Thinking) + } +} + +func TestLlamaCppProvider_ManagedMode_RequiresModelPath(t *testing.T) { + // No BaseURL and no ModelPath — should not panic + p := NewLlamaCppProvider(LlamaCppConfig{}) + if p.config.BaseURL == "" && p.config.ModelPath == "" { + // This is a misconfiguration but should not crash + _, err := p.Chat(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil) + if err == nil { + t.Error("expected error for misconfigured provider") + } + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestLlamaCppProvider" -v` +Expected: FAIL — `NewLlamaCppProvider` doesn't exist + +**Step 3: Implement llama_cpp.go** + +Create `provider/llama_cpp.go`: + +```go +package provider + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "runtime" + "sync" + "time" + + openaisdk "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/openai/openai-go/shared" +) + +const ( + defaultLlamaCppPort = 8081 + defaultLlamaCppContextSize = 8192 + defaultLlamaCppMaxTokens = 4096 +) + +// LlamaCppConfig holds configuration for the llama.cpp provider. +type LlamaCppConfig struct { + BaseURL string // external mode if set + ModelPath string // managed mode if set (path to .gguf file) + BinaryPath string // path to llama-server binary (optional) + GPULayers int // -ngl flag, default: -1 (all layers) + ContextSize int // -c flag, default: 8192 + Threads int // -t flag, default: runtime.NumCPU() + Port int // server port for managed mode, default: 8081 + MaxTokens int + HTTPClient *http.Client +} + +// LlamaCppProvider implements Provider using an OpenAI-compatible server +// (llama-server, vLLM, TGI, etc.) with optional process management. +type LlamaCppProvider struct { + client openaisdk.Client + config LlamaCppConfig + + mu sync.Mutex + cmd *exec.Cmd + started bool +} + +// NewLlamaCppProvider creates a new llama.cpp provider. +func NewLlamaCppProvider(cfg LlamaCppConfig) *LlamaCppProvider { + if cfg.Port <= 0 { + cfg.Port = defaultLlamaCppPort + } + if cfg.ContextSize <= 0 { + cfg.ContextSize = defaultLlamaCppContextSize + } + if cfg.Threads <= 0 { + cfg.Threads = runtime.NumCPU() + } + if cfg.GPULayers == 0 { + cfg.GPULayers = -1 // offload all layers by default + } + if cfg.MaxTokens <= 0 { + cfg.MaxTokens = defaultLlamaCppMaxTokens + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = http.DefaultClient + } + + baseURL := cfg.BaseURL + if baseURL == "" && cfg.ModelPath != "" { + baseURL = fmt.Sprintf("http://localhost:%d/v1", cfg.Port) + } + + opts := []option.RequestOption{ + option.WithAPIKey("no-key"), // local server, no auth + option.WithHTTPClient(cfg.HTTPClient), + } + if baseURL != "" { + opts = append(opts, option.WithBaseURL(baseURL)) + } + client := openaisdk.NewClient(opts...) + + return &LlamaCppProvider{ + client: client, + config: cfg, + } +} + +func (p *LlamaCppProvider) Name() string { return "llama_cpp" } + +func (p *LlamaCppProvider) AuthModeInfo() AuthModeInfo { + return LocalAuthMode("llama_cpp", "llama.cpp (Local)") +} + +// ensureServer starts the managed llama-server process if in managed mode. +func (p *LlamaCppProvider) ensureServer(ctx context.Context) error { + if p.config.BaseURL != "" { + return nil // external mode, nothing to manage + } + if p.config.ModelPath == "" { + return fmt.Errorf("llama_cpp: either base_url or model_path must be set") + } + + p.mu.Lock() + defer p.mu.Unlock() + + if p.started { + return nil + } + + // Find llama-server binary + binaryPath := p.config.BinaryPath + if binaryPath == "" { + var err error + binaryPath, err = exec.LookPath("llama-server") + if err != nil { + // Try auto-download + binaryPath, err = EnsureLlamaServer(ctx) + if err != nil { + return fmt.Errorf("llama_cpp: cannot find or download llama-server: %w", err) + } + } + } + + // Build command + args := []string{ + "-m", p.config.ModelPath, + "-c", fmt.Sprintf("%d", p.config.ContextSize), + "-t", fmt.Sprintf("%d", p.config.Threads), + "-ngl", fmt.Sprintf("%d", p.config.GPULayers), + "--port", fmt.Sprintf("%d", p.config.Port), + } + + p.cmd = exec.CommandContext(ctx, binaryPath, args...) + p.cmd.Stdout = io.Discard + p.cmd.Stderr = os.Stderr + + if err := p.cmd.Start(); err != nil { + return fmt.Errorf("llama_cpp: start server: %w", err) + } + + // Wait for health + healthURL := fmt.Sprintf("http://localhost:%d/health", p.config.Port) + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + resp, err := p.config.HTTPClient.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + p.started = true + return nil + } + } + time.Sleep(500 * time.Millisecond) + } + + // Timeout — kill the process + if p.cmd.Process != nil { + p.cmd.Process.Kill() + } + return fmt.Errorf("llama_cpp: server failed to become healthy within 2 minutes") +} + +func (p *LlamaCppProvider) Chat(ctx context.Context, messages []Message, tools []ToolDef) (*Response, error) { + if err := p.ensureServer(ctx); err != nil { + return nil, err + } + + params := openaisdk.ChatCompletionNewParams{ + Model: shared.ChatModel(p.config.ModelPath), + Messages: toOpenAIMessages(messages), + MaxTokens: openaisdk.Int(int64(p.config.MaxTokens)), + } + if len(tools) > 0 { + params.Tools = toOpenAITools(tools) + } + + resp, err := p.client.Chat.Completions.New(ctx, params) + if err != nil { + return nil, fmt.Errorf("llama_cpp: %w", err) + } + + result, err := fromOpenAIResponse(resp) + if err != nil { + return nil, err + } + + // Apply thinking extraction + if result.Content != "" { + thinking, content := ParseThinking(result.Content) + result.Thinking = thinking + result.Content = content + } + + return result, nil +} + +func (p *LlamaCppProvider) Stream(ctx context.Context, messages []Message, tools []ToolDef) (<-chan StreamEvent, error) { + if err := p.ensureServer(ctx); err != nil { + return nil, err + } + + params := openaisdk.ChatCompletionNewParams{ + Model: shared.ChatModel(p.config.ModelPath), + Messages: toOpenAIMessages(messages), + MaxTokens: openaisdk.Int(int64(p.config.MaxTokens)), + } + if len(tools) > 0 { + params.Tools = toOpenAITools(tools) + } + + stream := p.client.Chat.Completions.NewStreaming(ctx, params) + ch := make(chan StreamEvent, 16) + + go func() { + defer close(ch) + parser := &ThinkingStreamParser{} + + // Wrap the OpenAI stream and apply thinking extraction + innerCh := make(chan StreamEvent, 16) + go streamOpenAIEvents(stream, innerCh) + + for evt := range innerCh { + if evt.Type == "text" && evt.Text != "" { + // Feed through thinking parser + for _, parsed := range parser.Feed(evt.Text) { + ch <- parsed + } + } else { + ch <- evt + } + } + }() + + return ch, nil +} + +// Close stops the managed llama-server process if running. +func (p *LlamaCppProvider) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.cmd != nil && p.cmd.Process != nil { + p.cmd.Process.Kill() + p.cmd.Wait() + p.started = false + } + return nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestLlamaCppProvider" -v` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider/llama_cpp.go provider/llama_cpp_test.go +git commit -m "feat: add llama.cpp provider with external and managed modes" +``` + +--- + +### Task 7: llama-server Auto-Download + +**Files:** +- Create: `provider/llama_cpp_download.go` +- Create: `provider/llama_cpp_download_test.go` + +**Step 1: Write the failing tests** + +Create `provider/llama_cpp_download_test.go`: + +```go +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestParseLlamaCppRelease_FindsAsset(t *testing.T) { + release := ghRelease{ + TagName: "b1234", + Assets: []ghAsset{ + {Name: "llama-b1234-bin-ubuntu-x64.zip", BrowserDownloadURL: "https://example.com/linux.zip"}, + {Name: "llama-b1234-bin-macos-arm64.zip", BrowserDownloadURL: "https://example.com/macos.zip"}, + {Name: "llama-b1234-bin-macos-x64.zip", BrowserDownloadURL: "https://example.com/macos-x64.zip"}, + {Name: "llama-b1234-bin-win-x64.zip", BrowserDownloadURL: "https://example.com/windows.zip"}, + }, + } + + url, err := findAssetURL(release, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatalf("findAssetURL: %v", err) + } + if url == "" { + t.Error("expected non-empty URL") + } +} + +func TestEnsureLlamaServer_CachedBinary(t *testing.T) { + // Set up a fake cached binary + tmpDir := t.TempDir() + cacheDir := filepath.Join(tmpDir, "llama-server", "test-version") + os.MkdirAll(cacheDir, 0o755) + binaryName := "llama-server" + if runtime.GOOS == "windows" { + binaryName = "llama-server.exe" + } + binaryPath := filepath.Join(cacheDir, binaryName) + os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755) + + // Override cache dir for test + origCacheDir := llamaCppCacheDir + llamaCppCacheDir = filepath.Join(tmpDir, "llama-server") + defer func() { llamaCppCacheDir = origCacheDir }() + + path, err := EnsureLlamaServer(context.Background()) + if err != nil { + t.Fatalf("EnsureLlamaServer: %v", err) + } + if path != binaryPath { + t.Errorf("want %q, got %q", binaryPath, path) + } +} + +func TestEnsureLlamaServer_Downloads(t *testing.T) { + // Create a fake zip with a llama-server binary + tmpDir := t.TempDir() + + // Mock GitHub API + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/ggerganov/llama.cpp/releases/latest": + resp := ghRelease{ + TagName: "b9999", + Assets: []ghAsset{ + {Name: assetNameForPlatform("b9999", runtime.GOOS, runtime.GOARCH), BrowserDownloadURL: srv.URL + "/download/test.zip"}, + }, + } + // Need to declare this function + json.NewEncoder(w).Encode(resp) + case r.URL.Path == "/download/test.zip": + // Serve empty zip — test will fail on extraction but proves the flow + w.Header().Set("Content-Type", "application/zip") + w.Write([]byte("PK\x05\x06" + string(make([]byte, 18)))) // minimal empty zip + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + origCacheDir := llamaCppCacheDir + origAPIBase := llamaCppGitHubAPI + llamaCppCacheDir = filepath.Join(tmpDir, "llama-server") + llamaCppGitHubAPI = srv.URL + defer func() { + llamaCppCacheDir = origCacheDir + llamaCppGitHubAPI = origAPIBase + }() + + // This will attempt the download flow — may fail on zip extraction + // which is acceptable; we're testing the discovery + download path + _, _ = EnsureLlamaServer(context.Background()) + // The test verifies no panic and the HTTP calls are made correctly +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestParseLlamaCpp|TestEnsureLlamaServer" -v` +Expected: FAIL — functions don't exist + +**Step 3: Implement llama_cpp_download.go** + +Create `provider/llama_cpp_download.go`: + +```go +package provider + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// Package-level vars for test overrides. +var ( + llamaCppCacheDir = filepath.Join(userCacheDir(), "workflow", "llama-server") + llamaCppGitHubAPI = "https://api.github.com" +) + +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []ghAsset `json:"assets"` +} + +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// EnsureLlamaServer finds or downloads the llama-server binary. +// Search order: PATH lookup → cached version → download from GitHub releases. +func EnsureLlamaServer(ctx context.Context) (string, error) { + // Check cache first (any version) + if entries, err := os.ReadDir(llamaCppCacheDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + binaryName := "llama-server" + if runtime.GOOS == "windows" { + binaryName = "llama-server.exe" + } + candidate := filepath.Join(llamaCppCacheDir, entry.Name(), binaryName) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + } + + // Download latest release + release, err := fetchLatestRelease(ctx) + if err != nil { + return "", fmt.Errorf("fetch latest llama.cpp release: %w", err) + } + + downloadURL, err := findAssetURL(release, runtime.GOOS, runtime.GOARCH) + if err != nil { + return "", err + } + + versionDir := filepath.Join(llamaCppCacheDir, release.TagName) + if err := os.MkdirAll(versionDir, 0o755); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + // Download and extract + zipPath := filepath.Join(versionDir, "download.zip") + if err := downloadFile(ctx, downloadURL, zipPath); err != nil { + return "", fmt.Errorf("download llama-server: %w", err) + } + defer os.Remove(zipPath) + + binaryName := "llama-server" + if runtime.GOOS == "windows" { + binaryName = "llama-server.exe" + } + + if err := extractBinaryFromZip(zipPath, binaryName, versionDir); err != nil { + return "", fmt.Errorf("extract llama-server: %w", err) + } + + binaryPath := filepath.Join(versionDir, binaryName) + os.Chmod(binaryPath, 0o755) + return binaryPath, nil +} + +func fetchLatestRelease(ctx context.Context) (ghRelease, error) { + url := llamaCppGitHubAPI + "/repos/ggerganov/llama.cpp/releases/latest" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return ghRelease{}, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return ghRelease{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return ghRelease{}, fmt.Errorf("GitHub API: status %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + + var release ghRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return ghRelease{}, err + } + return release, nil +} + +// findAssetURL selects the correct release asset for the given OS/arch. +func findAssetURL(release ghRelease, goos, goarch string) (string, error) { + target := assetNameForPlatform(release.TagName, goos, goarch) + + for _, asset := range release.Assets { + if asset.Name == target { + return asset.BrowserDownloadURL, nil + } + } + + // Fuzzy match — sometimes naming conventions vary + osKey, archKey := platformKeys(goos, goarch) + for _, asset := range release.Assets { + lower := strings.ToLower(asset.Name) + if strings.Contains(lower, osKey) && strings.Contains(lower, archKey) && strings.HasSuffix(lower, ".zip") { + return asset.BrowserDownloadURL, nil + } + } + + return "", fmt.Errorf("no llama.cpp release asset found for %s/%s in release %s", goos, goarch, release.TagName) +} + +// assetNameForPlatform returns the expected asset filename. +func assetNameForPlatform(tag, goos, goarch string) string { + osKey, archKey := platformKeys(goos, goarch) + return fmt.Sprintf("llama-%s-bin-%s-%s.zip", tag, osKey, archKey) +} + +func platformKeys(goos, goarch string) (string, string) { + osKey := goos + archKey := goarch + switch goos { + case "darwin": + osKey = "macos" + case "linux": + osKey = "ubuntu" + case "windows": + osKey = "win" + } + switch goarch { + case "amd64": + archKey = "x64" + case "arm64": + // arm64 stays as-is for macOS, but check + archKey = "arm64" + } + return osKey, archKey +} + +func downloadFile(ctx context.Context, url, dest string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download: status %d", resp.StatusCode) + } + + f, err := os.Create(dest) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, resp.Body) + return err +} + +func extractBinaryFromZip(zipPath, binaryName, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + // Look for the binary in any subdirectory + if filepath.Base(f.Name) == binaryName { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + dest := filepath.Join(destDir, binaryName) + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, rc) + return err + } + } + + return fmt.Errorf("binary %q not found in zip archive", binaryName) +} + +func userCacheDir() string { + dir, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.TempDir(), "workflow-cache") + } + return dir +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestParseLlamaCpp|TestEnsureLlamaServer" -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider/llama_cpp_download.go provider/llama_cpp_download_test.go +git commit -m "feat: add llama-server auto-download from GitHub releases" +``` + +--- + +### Task 8: HuggingFace File Downloader + +**Files:** +- Create: `provider/huggingface.go` +- Create: `provider/huggingface_test.go` + +**Step 1: Write the failing tests** + +Create `provider/huggingface_test.go`: + +```go +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDownloadHuggingFaceFile_BasicDownload(t *testing.T) { + fileContent := "fake-model-data-1234567890" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // HF Hub resolve endpoint + if r.URL.Path == "/api/models/testuser/testmodel/revision/main" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"_id":"testmodel","modelId":"testuser/testmodel"}`)) + return + } + // Direct file download + if r.URL.Path == "/testuser/testmodel/resolve/main/model.gguf" { + w.Header().Set("Content-Length", "26") + w.Write([]byte(fileContent)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + tmpDir := t.TempDir() + + origBase := huggingFaceBaseURL + huggingFaceBaseURL = srv.URL + defer func() { huggingFaceBaseURL = origBase }() + + path, err := DownloadHuggingFaceFile(context.Background(), "testuser/testmodel", "model.gguf", tmpDir, nil) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile: %v", err) + } + + expected := filepath.Join(tmpDir, "testuser--testmodel", "model.gguf") + if path != expected { + t.Errorf("path: want %q, got %q", expected, path) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read file: %v", err) + } + if string(data) != fileContent { + t.Errorf("content: want %q, got %q", fileContent, string(data)) + } +} + +func TestDownloadHuggingFaceFile_AlreadyExists(t *testing.T) { + tmpDir := t.TempDir() + repoDir := filepath.Join(tmpDir, "testuser--testmodel") + os.MkdirAll(repoDir, 0o755) + existingPath := filepath.Join(repoDir, "model.gguf") + os.WriteFile(existingPath, []byte("existing"), 0o644) + + // No server needed — file already exists + path, err := DownloadHuggingFaceFile(context.Background(), "testuser/testmodel", "model.gguf", tmpDir, nil) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile: %v", err) + } + if path != existingPath { + t.Errorf("path: want %q, got %q", existingPath, path) + } +} + +func TestDownloadHuggingFaceFile_Progress(t *testing.T) { + fileContent := "fake-model-data" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/testuser/testmodel/resolve/main/model.gguf" { + w.Header().Set("Content-Length", "15") + w.Write([]byte(fileContent)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + tmpDir := t.TempDir() + origBase := huggingFaceBaseURL + huggingFaceBaseURL = srv.URL + defer func() { huggingFaceBaseURL = origBase }() + + var progressCalled bool + _, err := DownloadHuggingFaceFile(context.Background(), "testuser/testmodel", "model.gguf", tmpDir, func(pct float64) { + progressCalled = true + }) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile: %v", err) + } + if !progressCalled { + t.Error("expected progress callback to be called") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestDownloadHuggingFace" -v` +Expected: FAIL — `DownloadHuggingFaceFile` doesn't exist + +**Step 3: Implement huggingface.go** + +Create `provider/huggingface.go`: + +```go +package provider + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Package-level var for test overrides. +var huggingFaceBaseURL = "https://huggingface.co" + +// DownloadHuggingFaceFile downloads a file from a HuggingFace model repository. +// It supports any file type (GGUF, safetensors, bin, etc.) from any HF repo. +// Files are saved to outputDir//. +// If the file already exists, it returns the path without re-downloading. +// The progress callback receives percentage (0-100) if Content-Length is available. +func DownloadHuggingFaceFile(ctx context.Context, repo, filename, outputDir string, progress func(pct float64)) (string, error) { + // Sanitize repo name for filesystem + repoSlug := strings.ReplaceAll(repo, "/", "--") + destDir := filepath.Join(outputDir, repoSlug) + destPath := filepath.Join(destDir, filename) + + // Check if already downloaded + if info, err := os.Stat(destPath); err == nil && !info.IsDir() { + return destPath, nil + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("create output dir: %w", err) + } + + // Download from HF Hub + downloadURL := fmt.Sprintf("%s/%s/resolve/main/%s", huggingFaceBaseURL, repo, filename) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + // Support resume via Range header + tmpPath := destPath + ".part" + var startByte int64 + if info, err := os.Stat(tmpPath); err == nil { + startByte = info.Size() + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", startByte)) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("download request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + } + + // Determine total size + var totalSize int64 + if cl := resp.Header.Get("Content-Length"); cl != "" { + totalSize, _ = strconv.ParseInt(cl, 10, 64) + totalSize += startByte + } + + // Open file for writing (append if resuming) + flags := os.O_CREATE | os.O_WRONLY + if startByte > 0 && resp.StatusCode == http.StatusPartialContent { + flags |= os.O_APPEND + } else { + flags |= os.O_TRUNC + startByte = 0 + } + + f, err := os.OpenFile(tmpPath, flags, 0o644) + if err != nil { + return "", fmt.Errorf("open temp file: %w", err) + } + + // Copy with progress tracking + written := startByte + buf := make([]byte, 32*1024) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, writeErr := f.Write(buf[:n]); writeErr != nil { + f.Close() + return "", fmt.Errorf("write: %w", writeErr) + } + written += int64(n) + if progress != nil && totalSize > 0 { + progress(float64(written) / float64(totalSize) * 100) + } + } + if readErr != nil { + if readErr == io.EOF { + break + } + f.Close() + return "", fmt.Errorf("read: %w", readErr) + } + } + f.Close() + + // Rename temp to final + if err := os.Rename(tmpPath, destPath); err != nil { + return "", fmt.Errorf("rename temp file: %w", err) + } + + return destPath, nil +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./provider/ -run "TestDownloadHuggingFace" -v` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider/huggingface.go provider/huggingface_test.go +git commit -m "feat: add generic HuggingFace file downloader with resume support" +``` + +--- + +### Task 9: step.model_pull Step Type + +**Files:** +- Create: `step_model_pull.go` +- Create: `step_model_pull_test.go` + +**Step 1: Write the failing tests** + +Create `step_model_pull_test.go`: + +```go +package agent + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-agent/provider" + "github.com/GoCodeAlone/workflow/module" +) + +func TestModelPullStep_HuggingFace(t *testing.T) { + fileContent := "fake-gguf-model-bytes" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/testuser/testmodel/resolve/main/model.gguf" { + w.Header().Set("Content-Length", "21") + w.Write([]byte(fileContent)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + // Override HF base URL for test + origBase := provider.HuggingFaceBaseURL() + provider.SetHuggingFaceBaseURL(srv.URL) + defer provider.SetHuggingFaceBaseURL(origBase) + + tmpDir := t.TempDir() + + step := &ModelPullStep{ + name: "test_pull", + source: "huggingface", + model: "testuser/testmodel", + file: "model.gguf", + outputDir: tmpDir, + } + + pc := &module.PipelineContext{ + Current: map[string]any{}, + } + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + status, _ := result.Output["status"].(string) + if status != "downloaded" && status != "ready" { + t.Errorf("status: want downloaded or ready, got %q", status) + } + + modelPath, _ := result.Output["model_path"].(string) + if modelPath == "" { + t.Error("model_path: want non-empty") + } +} + +func TestModelPullStep_Ollama(t *testing.T) { + // Mock Ollama pull endpoint + pullCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/pull" && r.Method == "POST" { + pullCalled = true + // Return streaming progress then done + resp := map[string]any{"status": "success"} + json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + step := &ModelPullStep{ + name: "test_pull", + source: "ollama", + model: "qwen3.5:27b", + ollamaBase: srv.URL, + } + + pc := &module.PipelineContext{ + Current: map[string]any{}, + } + + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !pullCalled { + t.Error("expected Ollama pull API to be called") + } + _ = result // verify no panic +} + +func TestModelPullStepFactory(t *testing.T) { + factory := newModelPullStepFactory() + step, err := factory("test", map[string]any{ + "provider": "huggingface", + "model": "user/repo", + "file": "model.gguf", + "output_dir": "/tmp/models", + }, nil) + if err != nil { + t.Fatalf("factory: %v", err) + } + if step == nil { + t.Fatal("step is nil") + } + s, ok := step.(*ModelPullStep) + if !ok { + t.Fatalf("step type: want *ModelPullStep, got %T", step) + } + if s.source != "huggingface" { + t.Errorf("source: want huggingface, got %q", s.source) + } + if s.model != "user/repo" { + t.Errorf("model: want user/repo, got %q", s.model) + } + if s.file != "model.gguf" { + t.Errorf("file: want model.gguf, got %q", s.file) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test . -run "TestModelPullStep" -v` +Expected: FAIL — `ModelPullStep` doesn't exist + +**Step 3: Implement step_model_pull.go** + +Create `step_model_pull.go`: + +```go +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow-plugin-agent/provider" + "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/plugin" +) + +// ModelPullStep ensures a model is available locally before pipeline execution. +type ModelPullStep struct { + name string + source string // "ollama" or "huggingface" + model string // model identifier (ollama tag or HF repo) + file string // specific file to download (HF only) + outputDir string // download destination (HF only) + ollamaBase string // Ollama base URL (optional) +} + +func (s *ModelPullStep) Name() string { return s.name } + +func (s *ModelPullStep) Execute(ctx context.Context, pc *module.PipelineContext) (*module.StepResult, error) { + output := map[string]any{} + + switch s.source { + case "ollama": + if err := s.pullOllama(ctx, output); err != nil { + output["status"] = "error" + output["error"] = err.Error() + return &module.StepResult{Output: output}, nil + } + case "huggingface": + if err := s.pullHuggingFace(ctx, output); err != nil { + output["status"] = "error" + output["error"] = err.Error() + return &module.StepResult{Output: output}, nil + } + default: + return nil, fmt.Errorf("model_pull step %q: unknown provider %q (supported: ollama, huggingface)", s.name, s.source) + } + + return &module.StepResult{Output: output}, nil +} + +func (s *ModelPullStep) pullOllama(ctx context.Context, output map[string]any) error { + baseURL := s.ollamaBase + if baseURL == "" { + baseURL = "http://localhost:11434" + } + + p := provider.NewOllamaProvider(provider.OllamaConfig{ + Model: s.model, + BaseURL: baseURL, + }) + + err := p.Pull(ctx, s.model, func(status string, pct float64) { + // Could emit progress via pipeline metadata in the future + }) + if err != nil { + return fmt.Errorf("ollama pull %q: %w", s.model, err) + } + + output["status"] = "ready" + output["model_path"] = s.model + return nil +} + +func (s *ModelPullStep) pullHuggingFace(ctx context.Context, output map[string]any) error { + if s.file == "" { + return fmt.Errorf("huggingface provider requires 'file' config") + } + + outDir := s.outputDir + if outDir == "" { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + outDir = filepath.Join(cacheDir, "workflow", "models") + } + + path, err := provider.DownloadHuggingFaceFile(ctx, s.model, s.file, outDir, func(pct float64) { + // Could emit progress via pipeline metadata in the future + }) + if err != nil { + return fmt.Errorf("huggingface download %q/%q: %w", s.model, s.file, err) + } + + info, _ := os.Stat(path) + output["status"] = "downloaded" + output["model_path"] = path + if info != nil { + output["size_bytes"] = info.Size() + } + return nil +} + +// newModelPullStepFactory returns a plugin.StepFactory for "step.model_pull". +func newModelPullStepFactory() plugin.StepFactory { + return func(name string, cfg map[string]any, _ modular.Application) (any, error) { + source, _ := cfg["provider"].(string) + if source == "" { + source = "ollama" + } + model, _ := cfg["model"].(string) + file, _ := cfg["file"].(string) + outputDir, _ := cfg["output_dir"].(string) + ollamaBase, _ := cfg["base_url"].(string) + + return &ModelPullStep{ + name: name, + source: source, + model: model, + file: file, + outputDir: outputDir, + ollamaBase: ollamaBase, + }, nil + } +} +``` + +**Note:** You'll also need to expose the `huggingFaceBaseURL` var for testing. Add to `provider/huggingface.go`: + +```go +// HuggingFaceBaseURL returns the current HuggingFace base URL (for testing). +func HuggingFaceBaseURL() string { return huggingFaceBaseURL } + +// SetHuggingFaceBaseURL overrides the HuggingFace base URL (for testing). +func SetHuggingFaceBaseURL(url string) { huggingFaceBaseURL = url } +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test . -run "TestModelPullStep" -v` +Expected: ALL PASS + +**Step 5: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add step_model_pull.go step_model_pull_test.go provider/huggingface.go +git commit -m "feat: add step.model_pull for Ollama and HuggingFace model acquisition" +``` + +--- + +### Task 10: Executor — Thread Thinking Into Transcript and Output + +**Files:** +- Modify: `executor/executor.go:184-197` (transcript recording of thinking) +- Modify: `step_agent_execute.go:186-194` (step output) +- Modify: `executor/interfaces.go:12-22` (TranscriptEntry) + +**Step 1: Write the failing tests** + +Add to `executor/executor_test.go`: + +```go +func TestExecute_ThinkingInResult(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{ + Content: "The answer is 42.", + Thinking: "Let me reason about this carefully.", + }, + } + cfg := Config{Provider: p} + + result, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Thinking != "Let me reason about this carefully." { + t.Errorf("Thinking: want %q, got %q", "Let me reason about this carefully.", result.Thinking) + } +} + +func TestExecute_ThinkingInTranscript(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{ + Content: "The answer.", + Thinking: "My reasoning.", + }, + } + rec := &recordingTranscript{} + cfg := Config{Provider: p, Transcript: rec} + + _, err := Execute(context.Background(), cfg, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + + // Should have a thinking entry before the assistant entry + foundThinking := false + for _, entry := range rec.entries { + if entry.Thinking != "" { + foundThinking = true + if entry.Thinking != "My reasoning." { + t.Errorf("thinking content: want %q, got %q", "My reasoning.", entry.Thinking) + } + } + } + if !foundThinking { + t.Error("no thinking entry found in transcript") + } +} + +// recordingTranscript captures entries for test assertions. +type recordingTranscript struct { + entries []TranscriptEntry +} + +func (r *recordingTranscript) Record(_ context.Context, entry TranscriptEntry) error { + r.entries = append(r.entries, entry) + return nil +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./executor/ -run "TestExecute_Thinking" -v` +Expected: FAIL — `Result` has no `Thinking` field + +**Step 3: Add Thinking to Result** + +In `executor/executor.go`, add `Thinking` to `Result`: + +```go +type Result struct { + Content string + Thinking string + Iterations int + Status string + Error string +} +``` + +Add `Thinking` to `TranscriptEntry` in `executor/interfaces.go`: + +```go +type TranscriptEntry struct { + ID string + AgentID string + TaskID string + ProjectID string + Iteration int + Role provider.Role + Content string + Thinking string + ToolCalls []provider.ToolCall + ToolCallID string +} +``` + +In `executor/executor.go`, after `finalContent = resp.Content` (line 185), add: + +```go +var finalThinking string +``` + +And after `finalContent = resp.Content` in the loop: + +```go +finalThinking = resp.Thinking +``` + +Modify the transcript recording block (around line 188) to include thinking: + +```go + _ = cfg.Transcript.Record(ctx, TranscriptEntry{ + ID: uuid.New().String(), + AgentID: agentID, + TaskID: cfg.TaskID, + ProjectID: cfg.ProjectID, + Iteration: iterCount, + Role: provider.RoleAssistant, + Content: resp.Content, + Thinking: resp.Thinking, + ToolCalls: resp.ToolCalls, + }) +``` + +And update the final return (around line 342): + +```go + return &Result{ + Content: finalContent, + Thinking: finalThinking, + Status: "completed", + Iterations: iterCount, + }, nil +``` + +**Step 4: Update step_agent_execute.go output** + +In `step_agent_execute.go`, after `output["result"]` (line 187), add: + +```go + if result.Thinking != "" { + output["thinking"] = result.Thinking + } +``` + +**Step 5: Run tests to verify they pass** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./executor/ -run "TestExecute_Thinking" -v && go test ./...` +Expected: ALL PASS + +**Step 6: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add executor/executor.go executor/interfaces.go step_agent_execute.go executor/executor_test.go +git commit -m "feat: thread thinking traces through executor, transcript, and step output" +``` + +--- + +### Task 11: Registry, Plugin, Auth Mode, and Model Listing Wiring + +**Files:** +- Modify: `provider_registry.go:43-91` (add factories) +- Modify: `module_provider.go:96-211` (add cases) +- Modify: `plugin.go:36,56-62,100-122` (register step, update schema) +- Modify: `provider/auth_modes.go:15-32` (add local modes) +- Modify: `provider/models.go:22-59` (add cases) + +**Step 1: Add factories to provider_registry.go** + +After the `copilot` factory (line 88), add: + +```go + r.Factories["ollama"] = func(_ string, cfg LLMProviderConfig) (provider.Provider, error) { + return provider.NewOllamaProvider(provider.OllamaConfig{ + Model: cfg.Model, + BaseURL: cfg.BaseURL, + MaxTokens: cfg.MaxTokens, + }), nil + } + r.Factories["llama_cpp"] = func(_ string, cfg LLMProviderConfig) (provider.Provider, error) { + return provider.NewLlamaCppProvider(provider.LlamaCppConfig{ + BaseURL: cfg.BaseURL, + ModelPath: cfg.Model, + MaxTokens: cfg.MaxTokens, + }), nil + } +``` + +**Step 2: Add cases to module_provider.go** + +After the `copilot` case (line 207), before `default:`, add: + +```go + case "ollama": + p = provider.NewOllamaProvider(provider.OllamaConfig{ + Model: model, + BaseURL: baseURL, + MaxTokens: maxTokens, + }) + + case "llama_cpp": + p = provider.NewLlamaCppProvider(provider.LlamaCppConfig{ + BaseURL: baseURL, + ModelPath: model, + MaxTokens: maxTokens, + }) +``` + +Update the default error message to include the new types: + +```go + default: + p = &errProvider{err: fmt.Errorf("agent.provider %q: unrecognized provider type %q (supported: mock, test, anthropic, openai, copilot, ollama, llama_cpp)", name, providerType)} +``` + +**Step 3: Register step.model_pull in plugin.go** + +Update `Manifest.StepTypes` (line 36): + +```go +StepTypes: []string{"step.agent_execute", "step.provider_test", "step.provider_models", "step.model_pull"}, +``` + +Add to `StepFactories()`: + +```go +func (p *AgentPlugin) StepFactories() map[string]plugin.StepFactory { + return map[string]plugin.StepFactory{ + "step.agent_execute": newAgentExecuteStepFactory(), + "step.provider_test": newProviderTestFactory(), + "step.provider_models": newProviderModelsFactory(), + "step.model_pull": newModelPullStepFactory(), + } +} +``` + +Add exported factory: + +```go +// NewModelPullStepFactory returns the plugin.StepFactory for "step.model_pull". +func NewModelPullStepFactory() plugin.StepFactory { + return newModelPullStepFactory() +} +``` + +Update `ModuleSchemas()` — add `"ollama"` and `"llama_cpp"` to provider type Options: + +```go +{Key: "provider", Label: "Provider Type", Type: schema.FieldTypeSelect, Options: []string{"mock", "test", "anthropic", "openai", "copilot", "ollama", "llama_cpp"}, DefaultValue: "mock", Description: "AI provider backend"}, +``` + +**Step 4: Add auth modes to auth_modes.go** + +Add to the `AllAuthModes()` slice: + +```go + // Local inference + {Mode: "local", DisplayName: "Ollama (Local)", Description: "Local model inference via Ollama, no API key required.", ServerSafe: true}, + {Mode: "local", DisplayName: "llama.cpp (Local)", Description: "Local model inference via llama-server or compatible endpoint, no API key required.", ServerSafe: true}, +``` + +**Step 5: Add model listing to models.go** + +Add cases in `ListModels()` switch: + +```go + case "ollama": + return listOllamaModels(ctx, baseURL) + case "llama_cpp": + return []ModelInfo{{ID: "local", Name: "Local Model (configure via model_path)"}}, nil +``` + +Add the `listOllamaModels` function: + +```go +func listOllamaModels(ctx context.Context, baseURL string) ([]ModelInfo, error) { + p := NewOllamaProvider(OllamaConfig{BaseURL: baseURL}) + return p.ListModels(ctx) +} +``` + +**Step 6: Run all tests** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./...` +Expected: ALL PASS + +**Step 7: Commit** + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add provider_registry.go module_provider.go plugin.go provider/auth_modes.go provider/models.go +git commit -m "feat: wire Ollama and llama.cpp into registry, module factory, plugin manifest, and model listing" +``` + +--- + +### Task 12: Build Verification and Full Test Run + +**Step 1: Verify build compiles** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go build ./...` +Expected: PASS + +**Step 2: Run full test suite** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go test ./... -v -count=1` +Expected: ALL PASS + +**Step 3: Run go vet** + +Run: `cd /Users/jon/workspace/workflow-plugin-agent && go vet ./...` +Expected: No issues + +**Step 4: If any failures, fix and commit** + +Fix issues discovered by the full test run, then: + +```bash +cd /Users/jon/workspace/workflow-plugin-agent +git add -A +git commit -m "fix: resolve issues found during full build verification" +``` From 773cfd36b00b295daafac7179230f1a14e4247f7 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:07:32 -0400 Subject: [PATCH 03/14] feat: add Thinking fields to Response and StreamEvent Add Thinking string field to Response and StreamEvent structs in provider/provider.go to support reasoning traces from local models. Add "thinking" as a new StreamEvent type. Existing providers unaffected. Co-Authored-By: Claude Sonnet 4.6 --- provider/provider.go | 12 +++-- provider/thinking_field_test.go | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 provider/thinking_field_test.go diff --git a/provider/provider.go b/provider/provider.go index e7f151e..82282df 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -38,6 +38,7 @@ type ToolCall struct { // Response is a completed (non-streaming) provider response. type Response struct { Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` // reasoning trace (e.g. from tags) ToolCalls []ToolCall `json:"tool_calls,omitempty"` Usage Usage `json:"usage"` } @@ -50,11 +51,12 @@ type Usage struct { // StreamEvent is emitted during streaming responses. type StreamEvent struct { - Type string `json:"type"` // "text", "tool_call", "done", "error" - Text string `json:"text,omitempty"` - Tool *ToolCall `json:"tool,omitempty"` - Error string `json:"error,omitempty"` - Usage *Usage `json:"usage,omitempty"` + Type string `json:"type"` // "text", "thinking", "tool_call", "done", "error" + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + Tool *ToolCall `json:"tool,omitempty"` + Error string `json:"error,omitempty"` + Usage *Usage `json:"usage,omitempty"` } // Provider is an AI backend that powers agent reasoning. diff --git a/provider/thinking_field_test.go b/provider/thinking_field_test.go new file mode 100644 index 0000000..311b811 --- /dev/null +++ b/provider/thinking_field_test.go @@ -0,0 +1,90 @@ +package provider + +import ( + "encoding/json" + "testing" +) + +func TestResponseThinkingOmitEmpty(t *testing.T) { + r := Response{Content: "hello", Usage: Usage{InputTokens: 1, OutputTokens: 2}} + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["thinking"]; ok { + t.Error("thinking field should be omitted when empty") + } +} + +func TestResponseThinkingRoundTrip(t *testing.T) { + r := Response{Content: "answer", Thinking: "let me think", Usage: Usage{InputTokens: 5, OutputTokens: 3}} + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var got Response + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.Thinking != "let me think" { + t.Errorf("got Thinking=%q, want %q", got.Thinking, "let me think") + } + if got.Content != "answer" { + t.Errorf("got Content=%q, want %q", got.Content, "answer") + } +} + +func TestStreamEventThinkingType(t *testing.T) { + e := StreamEvent{Type: "thinking", Thinking: "reasoning here"} + b, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + var got StreamEvent + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.Type != "thinking" { + t.Errorf("got Type=%q, want %q", got.Type, "thinking") + } + if got.Thinking != "reasoning here" { + t.Errorf("got Thinking=%q, want %q", got.Thinking, "reasoning here") + } +} + +func TestStreamEventThinkingOmitEmpty(t *testing.T) { + e := StreamEvent{Type: "text", Text: "hello"} + b, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if _, ok := m["thinking"]; ok { + t.Error("thinking field should be omitted when empty") + } +} + +func TestStreamEventExistingTypesUnchanged(t *testing.T) { + types := []string{"text", "tool_call", "done", "error"} + for _, typ := range types { + e := StreamEvent{Type: typ} + b, err := json.Marshal(e) + if err != nil { + t.Fatalf("marshal type=%q: %v", typ, err) + } + var got StreamEvent + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal type=%q: %v", typ, err) + } + if got.Type != typ { + t.Errorf("got Type=%q, want %q", got.Type, typ) + } + } +} From 1c58f4dfe11670509d5e12e1dcba3b78f2c1bffa Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:07:36 -0400 Subject: [PATCH 04/14] feat: add github.com/ollama/ollama/api dependency Adds the Ollama client-only package needed for the Ollama provider implementation. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 1 + go.sum | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 248552c..3b8a8cb 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/digitalocean/godo v1.175.0 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dlclark/regexp2 v1.11.4 // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/go.sum b/go.sum index 280d8c7..f130924 100644 --- a/go.sum +++ b/go.sum @@ -207,8 +207,8 @@ github.com/digitalocean/godo v1.175.0 h1:tpfwJFkBzpePxvvFazOn69TXctdxuFlOs7DMVXs github.com/digitalocean/godo v1.175.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= From 9eb1d578f3ff2f1bd40e11b148bb55bd3e6278ed Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:09:41 -0400 Subject: [PATCH 05/14] feat: add shared local inference helpers (ParseThinking, ThinkingStreamParser, LocalAuthMode) Adds provider/local.go with: - ParseThinking: extracts ... blocks from model output - ThinkingStreamParser: streaming state machine handling tags split across chunks - LocalAuthMode: helper for no-API-key provider auth metadata Co-Authored-By: Claude Sonnet 4.6 --- provider/local.go | 101 +++++++++++++++++++++ provider/local_test.go | 198 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 provider/local.go create mode 100644 provider/local_test.go diff --git a/provider/local.go b/provider/local.go new file mode 100644 index 0000000..3dc1f26 --- /dev/null +++ b/provider/local.go @@ -0,0 +1,101 @@ +package provider + +import "strings" + +// ParseThinking extracts ... blocks from model output. +// The first block's content becomes thinking; the remainder becomes content. +// If no block is present, all text is returned as content. +func ParseThinking(raw string) (thinking, content string) { + start := strings.Index(raw, "") + if start == -1 { + return "", raw + } + end := strings.Index(raw[start:], "") + if end == -1 { + // Unclosed tag: treat everything after as thinking. + return strings.TrimSpace(raw[start+len(""):]), "" + } + end += start + thinking = strings.TrimSpace(raw[start+len("") : end]) + content = strings.TrimSpace(raw[end+len(""):]) + return thinking, content +} + +// ThinkingStreamParser tracks state across streaming chunks to split +// thinking vs content tokens. Handles / split across chunks. +type ThinkingStreamParser struct { + buf string // accumulated partial tag buffer + inThink bool // currently inside ... +} + +// Feed processes a streaming chunk and returns zero or more StreamEvents. +// Events may be of type "thinking" (Thinking field set) or "text" (Text field set). +func (p *ThinkingStreamParser) Feed(chunk string) []StreamEvent { + var events []StreamEvent + p.buf += chunk + + for { + if p.inThink { + // Look for + idx := strings.Index(p.buf, "") + if idx != -1 { + if idx > 0 { + events = append(events, StreamEvent{Type: "thinking", Thinking: p.buf[:idx]}) + } + p.buf = p.buf[idx+len(""):] + p.inThink = false + continue + } + // Check if buf ends with a partial "" prefix — hold it back. + partial := partialSuffix(p.buf, "") + safe := p.buf[:len(p.buf)-len(partial)] + if safe != "" { + events = append(events, StreamEvent{Type: "thinking", Thinking: safe}) + } + p.buf = partial + break + } + + // Not in think — look for + idx := strings.Index(p.buf, "") + if idx != -1 { + if idx > 0 { + events = append(events, StreamEvent{Type: "text", Text: p.buf[:idx]}) + } + p.buf = p.buf[idx+len(""):] + p.inThink = true + continue + } + // Check for partial "" prefix at end. + partial := partialSuffix(p.buf, "") + safe := p.buf[:len(p.buf)-len(partial)] + if safe != "" { + events = append(events, StreamEvent{Type: "text", Text: safe}) + } + p.buf = partial + break + } + + return events +} + +// partialSuffix returns the longest suffix of s that is a prefix of tag. +// Used to avoid splitting a tag across chunk boundaries. +func partialSuffix(s, tag string) string { + for i := len(tag) - 1; i > 0; i-- { + if strings.HasSuffix(s, tag[:i]) { + return tag[:i] + } + } + return "" +} + +// LocalAuthMode returns an AuthModeInfo for a local (no-API-key) provider. +func LocalAuthMode(name, displayName string) AuthModeInfo { + return AuthModeInfo{ + Mode: name, + DisplayName: displayName, + Description: "Local inference — no API key required.", + ServerSafe: true, + } +} diff --git a/provider/local_test.go b/provider/local_test.go new file mode 100644 index 0000000..1686482 --- /dev/null +++ b/provider/local_test.go @@ -0,0 +1,198 @@ +package provider + +import ( + "strings" + "testing" +) + +// --- ParseThinking --- + +func TestParseThinkingBasic(t *testing.T) { + thinking, content := ParseThinking("reasoninganswer") + if thinking != "reasoning" { + t.Errorf("thinking=%q, want %q", thinking, "reasoning") + } + if content != "answer" { + t.Errorf("content=%q, want %q", content, "answer") + } +} + +func TestParseThinkingNoTag(t *testing.T) { + thinking, content := ParseThinking("just the answer") + if thinking != "" { + t.Errorf("thinking=%q, want empty", thinking) + } + if content != "just the answer" { + t.Errorf("content=%q, want %q", content, "just the answer") + } +} + +func TestParseThinkingWhitespace(t *testing.T) { + thinking, content := ParseThinking(" think text result ") + if thinking != "think text" { + t.Errorf("thinking=%q, want trimmed", thinking) + } + if content != "result" { + t.Errorf("content=%q, want trimmed", content) + } +} + +func TestParseThinkingUnclosedTag(t *testing.T) { + thinking, content := ParseThinking("incomplete") + if thinking != "incomplete" { + t.Errorf("thinking=%q, want %q", thinking, "incomplete") + } + if content != "" { + t.Errorf("content=%q, want empty", content) + } +} + +func TestParseThinkingEmptyBlock(t *testing.T) { + thinking, content := ParseThinking("answer") + if thinking != "" { + t.Errorf("thinking=%q, want empty", thinking) + } + if content != "answer" { + t.Errorf("content=%q, want %q", content, "answer") + } +} + +func TestParseThinkingMultipleBlocks(t *testing.T) { + // Only the first block becomes thinking; remainder is content. + thinking, content := ParseThinking("firstmiddlesecondend") + if thinking != "first" { + t.Errorf("thinking=%q, want %q", thinking, "first") + } + // Content includes everything after the first + if !strings.Contains(content, "middle") { + t.Errorf("content=%q should contain 'middle'", content) + } +} + +// --- ThinkingStreamParser --- + +func collectEvents(p *ThinkingStreamParser, chunks []string) []StreamEvent { + var all []StreamEvent + for _, c := range chunks { + all = append(all, p.Feed(c)...) + } + return all +} + +func TestStreamParserNoThink(t *testing.T) { + var p ThinkingStreamParser + events := collectEvents(&p, []string{"hello ", "world"}) + for _, e := range events { + if e.Type != "text" { + t.Errorf("expected text event, got type=%q", e.Type) + } + } + text := joinText(events) + if text != "hello world" { + t.Errorf("text=%q, want %q", text, "hello world") + } +} + +func TestStreamParserBasicThink(t *testing.T) { + var p ThinkingStreamParser + events := collectEvents(&p, []string{"think thisanswer"}) + assertThinking(t, events, "think this") + assertText(t, events, "answer") +} + +func TestStreamParserTagSplitAcrossChunks(t *testing.T) { + var p ThinkingStreamParser + // Split "" across two chunks + events := collectEvents(&p, []string{"reasoningresult"}) + assertThinking(t, events, "reasoning") + assertText(t, events, "result") +} + +func TestStreamParserCloseTagSplit(t *testing.T) { + var p ThinkingStreamParser + // Split "" across two chunks + events := collectEvents(&p, []string{"reasoningresult"}) + assertThinking(t, events, "reasoning") + assertText(t, events, "result") +} + +func TestStreamParserThinkingOnly(t *testing.T) { + var p ThinkingStreamParser + events := collectEvents(&p, []string{"think"}) + assertThinking(t, events, "think") + if joinText(events) != "" { + t.Errorf("expected no text events") + } +} + +func TestStreamParserTextBeforeThink(t *testing.T) { + var p ThinkingStreamParser + events := collectEvents(&p, []string{"beforethinkingafter"}) + assertTextContains(t, events, "before") + assertThinking(t, events, "thinking") + assertTextContains(t, events, "after") +} + +// helpers + +func joinText(events []StreamEvent) string { + var b strings.Builder + for _, e := range events { + if e.Type == "text" { + b.WriteString(e.Text) + } + } + return b.String() +} + +func joinThinking(events []StreamEvent) string { + var b strings.Builder + for _, e := range events { + if e.Type == "thinking" { + b.WriteString(e.Thinking) + } + } + return b.String() +} + +func assertThinking(t *testing.T, events []StreamEvent, want string) { + t.Helper() + got := joinThinking(events) + if got != want { + t.Errorf("thinking=%q, want %q", got, want) + } +} + +func assertText(t *testing.T, events []StreamEvent, want string) { + t.Helper() + got := joinText(events) + if got != want { + t.Errorf("text=%q, want %q", got, want) + } +} + +func assertTextContains(t *testing.T, events []StreamEvent, sub string) { + t.Helper() + got := joinText(events) + if !strings.Contains(got, sub) { + t.Errorf("text=%q should contain %q", got, sub) + } +} + +// --- LocalAuthMode --- + +func TestLocalAuthMode(t *testing.T) { + info := LocalAuthMode("ollama", "Ollama (Local)") + if info.Mode != "ollama" { + t.Errorf("Mode=%q, want %q", info.Mode, "ollama") + } + if info.DisplayName != "Ollama (Local)" { + t.Errorf("DisplayName=%q", info.DisplayName) + } + if !info.ServerSafe { + t.Error("ServerSafe should be true for local providers") + } + if info.Warning != "" { + t.Errorf("Warning should be empty, got %q", info.Warning) + } +} From 3aa2045ca0fba63f0d277805c565a7224419d802 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:11:17 -0400 Subject: [PATCH 06/14] feat: thread thinking traces through executor and step output - Add Thinking field to TranscriptEntry and Result - Capture resp.Thinking into finalThinking in execute loop - Include Thinking in assistant transcript Record calls - Expose thinking in step output map when non-empty - Add TestExecute_ThinkingInResult and TestExecute_ThinkingInTranscript Co-Authored-By: Claude Sonnet 4.6 --- executor/executor.go | 5 +++ executor/executor_test.go | 64 +++++++++++++++++++++++++++++++++++++++ executor/interfaces.go | 1 + go.mod | 5 +++ go.sum | 11 +++++++ step_agent_execute.go | 3 ++ 6 files changed, 89 insertions(+) diff --git a/executor/executor.go b/executor/executor.go index 12766cf..55b902f 100644 --- a/executor/executor.go +++ b/executor/executor.go @@ -59,6 +59,7 @@ type Config struct { // Result is the outcome of an Execute call. type Result struct { Content string + Thinking string Iterations int Status string // "completed", "failed", "loop_detected", "approval_timeout", "request_expired" Error string @@ -142,6 +143,7 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st } var finalContent string + var finalThinking string iterCount := 0 ld := NewLoopDetector(cfg.LoopDetection) cm := NewContextManager(cfg.Provider.Name(), cfg.CompactionThreshold) @@ -183,6 +185,7 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st } finalContent = resp.Content + finalThinking = resp.Thinking // Record assistant response _ = cfg.Transcript.Record(ctx, TranscriptEntry{ @@ -193,6 +196,7 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st Iteration: iterCount, Role: provider.RoleAssistant, Content: resp.Content, + Thinking: resp.Thinking, ToolCalls: resp.ToolCalls, }) @@ -341,6 +345,7 @@ func Execute(ctx context.Context, cfg Config, systemPrompt, userTask, agentID st return &Result{ Content: finalContent, + Thinking: finalThinking, Status: "completed", Iterations: iterCount, }, nil diff --git a/executor/executor_test.go b/executor/executor_test.go index e077865..fcda8dd 100644 --- a/executor/executor_test.go +++ b/executor/executor_test.go @@ -215,6 +215,70 @@ func TestExecute_NullApproverIsDefault(t *testing.T) { } } +// TestExecute_ThinkingInResult verifies Result.Thinking is populated from provider response. +func TestExecute_ThinkingInResult(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{ + Content: "The answer is 42.", + Thinking: "Let me reason through this carefully...", + }, + } + result, err := Execute(context.Background(), Config{Provider: p}, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Thinking != "Let me reason through this carefully..." { + t.Errorf("Thinking: want %q, got %q", "Let me reason through this carefully...", result.Thinking) + } + if result.Content != "The answer is 42." { + t.Errorf("Content: want %q, got %q", "The answer is 42.", result.Content) + } +} + +// TestExecute_ThinkingInTranscript verifies transcript entries include thinking from provider response. +func TestExecute_ThinkingInTranscript(t *testing.T) { + p := &mockProvider{ + name: "mock", + chatResponse: &provider.Response{ + Content: "Done.", + Thinking: "Step-by-step reasoning here.", + }, + } + recorder := &recordingTranscript{} + result, err := Execute(context.Background(), Config{ + Provider: p, + Transcript: recorder, + }, "sys", "task", "agent-1") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Thinking != "Step-by-step reasoning here." { + t.Errorf("Result.Thinking: want %q, got %q", "Step-by-step reasoning here.", result.Thinking) + } + + var found bool + for _, entry := range recorder.entries { + if entry.Role == provider.RoleAssistant && entry.Thinking == "Step-by-step reasoning here." { + found = true + break + } + } + if !found { + t.Error("expected an assistant transcript entry with Thinking populated") + } +} + +// recordingTranscript captures all transcript entries for inspection. +type recordingTranscript struct { + entries []TranscriptEntry +} + +func (r *recordingTranscript) Record(_ context.Context, entry TranscriptEntry) error { + r.entries = append(r.entries, entry) + return nil +} + // --- test helpers --- // callCountProvider calls onChat for each Chat invocation. diff --git a/executor/interfaces.go b/executor/interfaces.go index b4170a7..757684e 100644 --- a/executor/interfaces.go +++ b/executor/interfaces.go @@ -17,6 +17,7 @@ type TranscriptEntry struct { Iteration int Role provider.Role Content string + Thinking string ToolCalls []provider.ToolCall ToolCallID string } diff --git a/go.mod b/go.mod index 3b8a8cb..2c0e4a3 100644 --- a/go.mod +++ b/go.mod @@ -75,8 +75,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -145,6 +147,7 @@ require ( github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -158,6 +161,7 @@ require ( github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/ollama/ollama v0.18.3 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect @@ -186,6 +190,7 @@ require ( github.com/tochemey/olric v0.3.8 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect diff --git a/go.sum b/go.sum index f130924..5147a0a 100644 --- a/go.sum +++ b/go.sum @@ -150,6 +150,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -161,6 +163,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -433,6 +437,7 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -465,6 +470,8 @@ github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6 github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -522,6 +529,8 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ollama/ollama v0.18.3 h1:sOJhncLeA+ZlwLLjEksY393wojeYE52E1CF4E0ZgFN4= +github.com/ollama/ollama v0.18.3/go.mod h1:tCX4IMV8DHjl3zY0THxuEkpWDZSOchJpzTuLACpMwFw= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -659,6 +668,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= diff --git a/step_agent_execute.go b/step_agent_execute.go index e378db6..2b5b458 100644 --- a/step_agent_execute.go +++ b/step_agent_execute.go @@ -188,6 +188,9 @@ func (s *AgentExecuteStep) Execute(ctx context.Context, pc *module.PipelineConte "status": result.Status, "iterations": result.Iterations, } + if result.Thinking != "" { + output["thinking"] = result.Thinking + } if result.Error != "" { output["error"] = result.Error } From 471073741097d394e6598dda8005f3911ceeacf3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:11:25 -0400 Subject: [PATCH 07/14] feat: add Ollama type conversion helpers Adds provider/ollama_convert.go with: - toOllamaMessages: converts provider messages to Ollama API messages - toOllamaTools: converts ToolDef to Ollama Tool via JSON schema marshal/unmarshal - fromOllamaResponse: converts Ollama response to provider Response with ParseThinking applied - fromOllamaStreamChunk: extracts text, tool calls, and done flag from streaming chunks Co-Authored-By: Claude Sonnet 4.6 --- provider/ollama_convert.go | 96 +++++++++++++ provider/ollama_convert_test.go | 233 ++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 provider/ollama_convert.go create mode 100644 provider/ollama_convert_test.go diff --git a/provider/ollama_convert.go b/provider/ollama_convert.go new file mode 100644 index 0000000..2e90568 --- /dev/null +++ b/provider/ollama_convert.go @@ -0,0 +1,96 @@ +package provider + +import ( + "encoding/json" + + ollamaapi "github.com/ollama/ollama/api" +) + +// toOllamaMessages converts provider messages to Ollama API messages. +func toOllamaMessages(msgs []Message) []ollamaapi.Message { + result := make([]ollamaapi.Message, 0, len(msgs)) + for _, msg := range msgs { + m := ollamaapi.Message{ + Role: string(msg.Role), + Content: msg.Content, + ToolCallID: msg.ToolCallID, + } + for _, tc := range msg.ToolCalls { + args := ollamaapi.NewToolCallFunctionArguments() + for k, v := range tc.Arguments { + args.Set(k, v) + } + m.ToolCalls = append(m.ToolCalls, ollamaapi.ToolCall{ + ID: tc.ID, + Function: ollamaapi.ToolCallFunction{ + Name: tc.Name, + Arguments: args, + }, + }) + } + result = append(result, m) + } + return result +} + +// toOllamaTools converts provider tool definitions to Ollama API tools. +// The JSON Schema parameters are marshaled and unmarshaled into the Ollama type. +func toOllamaTools(tools []ToolDef) []ollamaapi.Tool { + result := make([]ollamaapi.Tool, 0, len(tools)) + for _, t := range tools { + var params ollamaapi.ToolFunctionParameters + if b, err := json.Marshal(t.Parameters); err == nil { + _ = json.Unmarshal(b, ¶ms) + } + result = append(result, ollamaapi.Tool{ + Type: "function", + Function: ollamaapi.ToolFunction{ + Name: t.Name, + Description: t.Description, + Parameters: params, + }, + }) + } + return result +} + +// fromOllamaResponse converts a completed Ollama ChatResponse to a provider Response. +// ParseThinking is applied to extract any ... block from the content. +func fromOllamaResponse(resp ollamaapi.ChatResponse) *Response { + thinking, content := ParseThinking(resp.Message.Content) + // Prefer native thinking field if set (Ollama Think mode) + if resp.Message.Thinking != "" { + thinking = resp.Message.Thinking + content = resp.Message.Content + } + r := &Response{ + Content: content, + Thinking: thinking, + Usage: Usage{ + InputTokens: resp.PromptEvalCount, + OutputTokens: resp.EvalCount, + }, + } + for _, tc := range resp.Message.ToolCalls { + r.ToolCalls = append(r.ToolCalls, ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments.ToMap(), + }) + } + return r +} + +// fromOllamaStreamChunk extracts text content, tool calls, and done flag from a +// streaming Ollama ChatResponse chunk. +func fromOllamaStreamChunk(resp ollamaapi.ChatResponse) (text string, toolCalls []ToolCall, done bool) { + text = resp.Message.Content + for _, tc := range resp.Message.ToolCalls { + toolCalls = append(toolCalls, ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: tc.Function.Arguments.ToMap(), + }) + } + return text, toolCalls, resp.Done +} diff --git a/provider/ollama_convert_test.go b/provider/ollama_convert_test.go new file mode 100644 index 0000000..1e3d51c --- /dev/null +++ b/provider/ollama_convert_test.go @@ -0,0 +1,233 @@ +package provider + +import ( + "testing" + "time" + + ollamaapi "github.com/ollama/ollama/api" +) + +// --- toOllamaMessages --- + +func TestToOllamaMessages_Roles(t *testing.T) { + msgs := []Message{ + {Role: RoleSystem, Content: "sys"}, + {Role: RoleUser, Content: "hi"}, + {Role: RoleAssistant, Content: "hello"}, + {Role: RoleTool, Content: "tool result", ToolCallID: "tc1"}, + } + got := toOllamaMessages(msgs) + if len(got) != 4 { + t.Fatalf("len=%d, want 4", len(got)) + } + if got[0].Role != "system" || got[0].Content != "sys" { + t.Errorf("system msg wrong: %+v", got[0]) + } + if got[1].Role != "user" || got[1].Content != "hi" { + t.Errorf("user msg wrong: %+v", got[1]) + } + if got[2].Role != "assistant" || got[2].Content != "hello" { + t.Errorf("assistant msg wrong: %+v", got[2]) + } + if got[3].Role != "tool" || got[3].ToolCallID != "tc1" { + t.Errorf("tool msg wrong: %+v", got[3]) + } +} + +func TestToOllamaMessages_ToolCalls(t *testing.T) { + msgs := []Message{ + { + Role: RoleAssistant, + ToolCalls: []ToolCall{ + {ID: "id1", Name: "search", Arguments: map[string]any{"q": "hello"}}, + }, + }, + } + got := toOllamaMessages(msgs) + if len(got[0].ToolCalls) != 1 { + t.Fatalf("tool calls len=%d, want 1", len(got[0].ToolCalls)) + } + tc := got[0].ToolCalls[0] + if tc.ID != "id1" { + t.Errorf("ID=%q, want %q", tc.ID, "id1") + } + if tc.Function.Name != "search" { + t.Errorf("Name=%q, want %q", tc.Function.Name, "search") + } + v, ok := tc.Function.Arguments.Get("q") + if !ok || v != "hello" { + t.Errorf("arg q=%v ok=%v, want 'hello'", v, ok) + } +} + +// --- toOllamaTools --- + +func TestToOllamaTools(t *testing.T) { + tools := []ToolDef{ + { + Name: "calculator", + Description: "does math", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "expr": map[string]any{"type": "string"}, + }, + "required": []any{"expr"}, + }, + }, + } + got := toOllamaTools(tools) + if len(got) != 1 { + t.Fatalf("len=%d, want 1", len(got)) + } + if got[0].Type != "function" { + t.Errorf("Type=%q, want 'function'", got[0].Type) + } + if got[0].Function.Name != "calculator" { + t.Errorf("Name=%q", got[0].Function.Name) + } + if got[0].Function.Description != "does math" { + t.Errorf("Description=%q", got[0].Function.Description) + } +} + +func TestToOllamaTools_Empty(t *testing.T) { + got := toOllamaTools(nil) + if len(got) != 0 { + t.Errorf("expected empty slice, got %d", len(got)) + } +} + +// --- fromOllamaResponse --- + +func TestFromOllamaResponse_Basic(t *testing.T) { + resp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{Role: "assistant", Content: "hello world"}, + Metrics: ollamaapi.Metrics{PromptEvalCount: 10, EvalCount: 5}, + } + got := fromOllamaResponse(resp) + if got.Content != "hello world" { + t.Errorf("Content=%q, want %q", got.Content, "hello world") + } + if got.Thinking != "" { + t.Errorf("Thinking=%q, want empty", got.Thinking) + } + if got.Usage.InputTokens != 10 { + t.Errorf("InputTokens=%d, want 10", got.Usage.InputTokens) + } + if got.Usage.OutputTokens != 5 { + t.Errorf("OutputTokens=%d, want 5", got.Usage.OutputTokens) + } +} + +func TestFromOllamaResponse_ThinkTagExtraction(t *testing.T) { + resp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{ + Role: "assistant", + Content: "reasoninganswer", + }, + } + got := fromOllamaResponse(resp) + if got.Thinking != "reasoning" { + t.Errorf("Thinking=%q, want %q", got.Thinking, "reasoning") + } + if got.Content != "answer" { + t.Errorf("Content=%q, want %q", got.Content, "answer") + } +} + +func TestFromOllamaResponse_NativeThinkingField(t *testing.T) { + // When Thinking is set natively (Ollama Think mode), prefer it. + resp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{ + Role: "assistant", + Content: "answer", + Thinking: "native thinking", + }, + } + got := fromOllamaResponse(resp) + if got.Thinking != "native thinking" { + t.Errorf("Thinking=%q, want %q", got.Thinking, "native thinking") + } + if got.Content != "answer" { + t.Errorf("Content=%q, want %q", got.Content, "answer") + } +} + +func TestFromOllamaResponse_ToolCalls(t *testing.T) { + args := ollamaapi.NewToolCallFunctionArguments() + args.Set("query", "test") + resp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{ + Role: "assistant", + ToolCalls: []ollamaapi.ToolCall{ + {ID: "tc1", Function: ollamaapi.ToolCallFunction{Name: "search", Arguments: args}}, + }, + }, + } + got := fromOllamaResponse(resp) + if len(got.ToolCalls) != 1 { + t.Fatalf("ToolCalls len=%d, want 1", len(got.ToolCalls)) + } + tc := got.ToolCalls[0] + if tc.ID != "tc1" || tc.Name != "search" { + t.Errorf("tc=%+v", tc) + } + if tc.Arguments["query"] != "test" { + t.Errorf("arg query=%v, want 'test'", tc.Arguments["query"]) + } +} + +// --- fromOllamaStreamChunk --- + +func TestFromOllamaStreamChunk_Text(t *testing.T) { + resp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{Role: "assistant", Content: "chunk text"}, + Done: false, + CreatedAt: time.Now(), + } + text, toolCalls, done := fromOllamaStreamChunk(resp) + if text != "chunk text" { + t.Errorf("text=%q, want %q", text, "chunk text") + } + if len(toolCalls) != 0 { + t.Errorf("toolCalls=%v, want empty", toolCalls) + } + if done { + t.Error("done should be false") + } +} + +func TestFromOllamaStreamChunk_Done(t *testing.T) { + resp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{Role: "assistant", Content: ""}, + Done: true, + CreatedAt: time.Now(), + } + _, _, done := fromOllamaStreamChunk(resp) + if !done { + t.Error("done should be true") + } +} + +func TestFromOllamaStreamChunk_ToolCalls(t *testing.T) { + args := ollamaapi.NewToolCallFunctionArguments() + args.Set("x", 42) + resp := ollamaapi.ChatResponse{ + Message: ollamaapi.Message{ + Role: "assistant", + ToolCalls: []ollamaapi.ToolCall{ + {ID: "tc2", Function: ollamaapi.ToolCallFunction{Name: "calc", Arguments: args}}, + }, + }, + Done: true, + CreatedAt: time.Now(), + } + _, toolCalls, _ := fromOllamaStreamChunk(resp) + if len(toolCalls) != 1 { + t.Fatalf("len=%d, want 1", len(toolCalls)) + } + if toolCalls[0].Name != "calc" { + t.Errorf("Name=%q, want %q", toolCalls[0].Name, "calc") + } +} From 3c364542e2540912936ddf6638c19d9879b90db0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:14:22 -0400 Subject: [PATCH 08/14] feat: add OllamaProvider implementing Provider interface Adds provider/ollama.go with OllamaProvider: - Chat(): non-streaming via Ollama API with ParseThinking applied - Stream(): streaming with ThinkingStreamParser for tag splitting - Pull(): model download with progress callback - ListModels(): returns models from /api/tags - Health(): heartbeat check via HEAD / Tests mock the Ollama HTTP API to cover chat, think-tag extraction, streaming, streaming with think tags, health, and list models. Co-Authored-By: Claude Sonnet 4.6 --- provider/ollama.go | 158 +++++++++++++++++++++++++++++ provider/ollama_test.go | 217 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 provider/ollama.go create mode 100644 provider/ollama_test.go diff --git a/provider/ollama.go b/provider/ollama.go new file mode 100644 index 0000000..5de041e --- /dev/null +++ b/provider/ollama.go @@ -0,0 +1,158 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + "net/url" + + ollamaapi "github.com/ollama/ollama/api" +) + +const defaultOllamaBaseURL = "http://localhost:11434" + +// OllamaConfig holds configuration for the Ollama provider. +type OllamaConfig struct { + Model string + BaseURL string + MaxTokens int + HTTPClient *http.Client +} + +// OllamaProvider implements Provider using a local Ollama server. +type OllamaProvider struct { + client *ollamaapi.Client + config OllamaConfig +} + +// NewOllamaProvider creates a new OllamaProvider with the given config. +func NewOllamaProvider(cfg OllamaConfig) *OllamaProvider { + if cfg.BaseURL == "" { + cfg.BaseURL = defaultOllamaBaseURL + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = http.DefaultClient + } + base, _ := url.Parse(cfg.BaseURL) + return &OllamaProvider{ + client: ollamaapi.NewClient(base, cfg.HTTPClient), + config: cfg, + } +} + +func (p *OllamaProvider) Name() string { return "ollama" } + +func (p *OllamaProvider) AuthModeInfo() AuthModeInfo { + return LocalAuthMode("ollama", "Ollama (Local)") +} + +// Chat sends a non-streaming request and returns the complete response. +func (p *OllamaProvider) Chat(ctx context.Context, messages []Message, tools []ToolDef) (*Response, error) { + streamFalse := false + req := &ollamaapi.ChatRequest{ + Model: p.config.Model, + Messages: toOllamaMessages(messages), + Stream: &streamFalse, + Options: map[string]any{}, + } + if len(tools) > 0 { + req.Tools = toOllamaTools(tools) + } + if p.config.MaxTokens > 0 { + req.Options["num_predict"] = p.config.MaxTokens + } + + var final ollamaapi.ChatResponse + if err := p.client.Chat(ctx, req, func(resp ollamaapi.ChatResponse) error { + final = resp + return nil + }); err != nil { + return nil, fmt.Errorf("ollama: %w", err) + } + return fromOllamaResponse(final), nil +} + +// Stream sends a streaming request and emits events on the returned channel. +// Thinking tokens extracted via ThinkingStreamParser are emitted as "thinking" events. +func (p *OllamaProvider) Stream(ctx context.Context, messages []Message, tools []ToolDef) (<-chan StreamEvent, error) { + req := &ollamaapi.ChatRequest{ + Model: p.config.Model, + Messages: toOllamaMessages(messages), + Options: map[string]any{}, + } + if len(tools) > 0 { + req.Tools = toOllamaTools(tools) + } + if p.config.MaxTokens > 0 { + req.Options["num_predict"] = p.config.MaxTokens + } + + ch := make(chan StreamEvent, 16) + go func() { + defer close(ch) + var parser ThinkingStreamParser + var usage *Usage + + err := p.client.Chat(ctx, req, func(resp ollamaapi.ChatResponse) error { + text, toolCalls, done := fromOllamaStreamChunk(resp) + if text != "" { + for _, ev := range parser.Feed(text) { + ch <- ev + } + } + for i := range toolCalls { + ch <- StreamEvent{Type: "tool_call", Tool: &toolCalls[i]} + } + if done { + usage = &Usage{ + InputTokens: resp.PromptEvalCount, + OutputTokens: resp.EvalCount, + } + } + return nil + }) + if err != nil { + ch <- StreamEvent{Type: "error", Error: err.Error()} + return + } + ch <- StreamEvent{Type: "done", Usage: usage} + }() + return ch, nil +} + +// Pull downloads a model via the Ollama server. +// progressFn is called with percent completion (0–100); may be nil. +func (p *OllamaProvider) Pull(ctx context.Context, model string, progressFn func(pct float64)) error { + req := &ollamaapi.PullRequest{Model: model} + return p.client.Pull(ctx, req, func(resp ollamaapi.ProgressResponse) error { + if progressFn != nil && resp.Total > 0 { + progressFn(float64(resp.Completed) / float64(resp.Total) * 100) + } + return nil + }) +} + +// ListModels returns the models available on the Ollama server. +func (p *OllamaProvider) ListModels(ctx context.Context) ([]ModelInfo, error) { + resp, err := p.client.List(ctx) + if err != nil { + return nil, fmt.Errorf("ollama: list models: %w", err) + } + models := make([]ModelInfo, 0, len(resp.Models)) + for _, m := range resp.Models { + name := m.Name + if name == "" { + name = m.Model + } + models = append(models, ModelInfo{ID: name, Name: name}) + } + return models, nil +} + +// Health checks whether the Ollama server is reachable. +func (p *OllamaProvider) Health(ctx context.Context) error { + if err := p.client.Heartbeat(ctx); err != nil { + return fmt.Errorf("ollama: health check: %w", err) + } + return nil +} diff --git a/provider/ollama_test.go b/provider/ollama_test.go new file mode 100644 index 0000000..b0b31ea --- /dev/null +++ b/provider/ollama_test.go @@ -0,0 +1,217 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// ollamaChatNDJSON builds a minimal Ollama chat NDJSON response line. +func ollamaChatNDJSON(content string, done bool) string { + resp := map[string]any{ + "model": "test", + "created_at": time.Now().Format(time.RFC3339), + "message": map[string]any{ + "role": "assistant", + "content": content, + }, + "done": done, + "prompt_eval_count": 3, + "eval_count": 7, + } + b, _ := json.Marshal(resp) + return string(b) + "\n" +} + +// ollamaMockServer sets up a minimal Ollama HTTP server for unit tests. +func ollamaMockServer(t *testing.T, chatContent string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + + // HEAD / — heartbeat + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + } + }) + + // POST /api/chat — chat (streaming NDJSON) + mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.Write([]byte(ollamaChatNDJSON(chatContent, true))) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + }) + + // GET /api/tags — list models + mux.HandleFunc("/api/tags", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "models": []map[string]any{ + {"name": "llama3.2:latest", "model": "llama3.2:latest", "size": int64(4000000000)}, + }, + }) + }) + + return httptest.NewServer(mux) +} + +func TestOllamaProvider_ImplementsProvider(t *testing.T) { + var _ Provider = (*OllamaProvider)(nil) +} + +func TestOllamaProvider_Name(t *testing.T) { + p := NewOllamaProvider(OllamaConfig{Model: "qwen3.5:7b"}) + if got := p.Name(); got != "ollama" { + t.Errorf("Name()=%q, want %q", got, "ollama") + } +} + +func TestOllamaProvider_AuthModeInfo(t *testing.T) { + p := NewOllamaProvider(OllamaConfig{}) + info := p.AuthModeInfo() + if info.Mode != "ollama" { + t.Errorf("Mode=%q, want %q", info.Mode, "ollama") + } + if !info.ServerSafe { + t.Error("ServerSafe should be true") + } + if info.Warning != "" { + t.Errorf("Warning should be empty, got %q", info.Warning) + } +} + +func TestOllamaProvider_DefaultBaseURL(t *testing.T) { + p := NewOllamaProvider(OllamaConfig{}) + if p.config.BaseURL != defaultOllamaBaseURL { + t.Errorf("BaseURL=%q, want %q", p.config.BaseURL, defaultOllamaBaseURL) + } +} + +func TestOllamaProvider_Health(t *testing.T) { + srv := ollamaMockServer(t, "") + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{BaseURL: srv.URL, Model: "test"}) + if err := p.Health(context.Background()); err != nil { + t.Errorf("Health() error: %v", err) + } +} + +func TestOllamaProvider_Chat(t *testing.T) { + srv := ollamaMockServer(t, "hello world") + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{BaseURL: srv.URL, Model: "test"}) + resp, err := p.Chat(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "hello world" { + t.Errorf("Content=%q, want %q", resp.Content, "hello world") + } + if resp.Usage.InputTokens != 3 { + t.Errorf("InputTokens=%d, want 3", resp.Usage.InputTokens) + } + if resp.Usage.OutputTokens != 7 { + t.Errorf("OutputTokens=%d, want 7", resp.Usage.OutputTokens) + } +} + +func TestOllamaProvider_ChatWithThinkTags(t *testing.T) { + srv := ollamaMockServer(t, "reasoninganswer") + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{BaseURL: srv.URL, Model: "test"}) + resp, err := p.Chat(context.Background(), []Message{{Role: RoleUser, Content: "q"}}, nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Thinking != "reasoning" { + t.Errorf("Thinking=%q, want %q", resp.Thinking, "reasoning") + } + if resp.Content != "answer" { + t.Errorf("Content=%q, want %q", resp.Content, "answer") + } +} + +func TestOllamaProvider_Stream(t *testing.T) { + srv := ollamaMockServer(t, "stream result") + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{BaseURL: srv.URL, Model: "test"}) + ch, err := p.Stream(context.Background(), []Message{{Role: RoleUser, Content: "hi"}}, nil) + if err != nil { + t.Fatalf("Stream() error: %v", err) + } + + var textParts []string + var sawDone bool + for ev := range ch { + switch ev.Type { + case "text": + textParts = append(textParts, ev.Text) + case "done": + sawDone = true + case "error": + t.Fatalf("stream error: %s", ev.Error) + } + } + if !sawDone { + t.Error("expected done event") + } + full := strings.Join(textParts, "") + if full != "stream result" { + t.Errorf("text=%q, want %q", full, "stream result") + } +} + +func TestOllamaProvider_StreamThinkTags(t *testing.T) { + srv := ollamaMockServer(t, "thinkingcontent") + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{BaseURL: srv.URL, Model: "test"}) + ch, err := p.Stream(context.Background(), []Message{{Role: RoleUser, Content: "q"}}, nil) + if err != nil { + t.Fatalf("Stream() error: %v", err) + } + + var thinkParts, textParts []string + for ev := range ch { + switch ev.Type { + case "thinking": + thinkParts = append(thinkParts, ev.Thinking) + case "text": + textParts = append(textParts, ev.Text) + case "error": + t.Fatalf("stream error: %s", ev.Error) + } + } + if strings.Join(thinkParts, "") != "thinking" { + t.Errorf("thinking=%q, want %q", strings.Join(thinkParts, ""), "thinking") + } + if strings.Join(textParts, "") != "content" { + t.Errorf("text=%q, want %q", strings.Join(textParts, ""), "content") + } +} + +func TestOllamaProvider_ListModels(t *testing.T) { + srv := ollamaMockServer(t, "") + defer srv.Close() + + p := NewOllamaProvider(OllamaConfig{BaseURL: srv.URL, Model: "test"}) + models, err := p.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error: %v", err) + } + if len(models) != 1 { + t.Fatalf("len=%d, want 1", len(models)) + } + if models[0].ID != "llama3.2:latest" { + t.Errorf("ID=%q, want %q", models[0].ID, "llama3.2:latest") + } +} From 243dd1d47208968cbabd3450747a091c0defb6ea Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:15:23 -0400 Subject: [PATCH 09/14] test: add ThinkingStreamParser nested and unclosed tag tests Adds two missing tests per design doc Section 8: - TestStreamParserNestedThinkTags: verifies nested inside open block is treated as literal content (no recursion); first closes the block - TestStreamParserUnclosedTag: verifies parser stays in inThink state when chunk has with no closing tag, emitting thinking events and continuing correctly on subsequent feeds Co-Authored-By: Claude Sonnet 4.6 --- provider/local_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/provider/local_test.go b/provider/local_test.go index 1686482..1f29b89 100644 --- a/provider/local_test.go +++ b/provider/local_test.go @@ -133,6 +133,46 @@ func TestStreamParserTextBeforeThink(t *testing.T) { assertTextContains(t, events, "after") } +func TestStreamParserNestedThinkTags(t *testing.T) { + // A nested inside an open block should be treated as + // literal thinking content — the parser does not recurse. + var p ThinkingStreamParser + events := collectEvents(&p, []string{"outerinnerrest"}) + // The first closes the block; "outerinner" is the thinking. + got := joinThinking(events) + if !strings.Contains(got, "outer") { + t.Errorf("thinking=%q, should contain 'outer'", got) + } + if !strings.Contains(got, "inner") { + t.Errorf("thinking=%q, should contain 'inner'", got) + } + // Everything after is text. + assertTextContains(t, events, "rest") +} + +func TestStreamParserUnclosedTag(t *testing.T) { + // Feed a chunk with an opened but no closing tag. + // The parser should stay in inThink state and emit thinking events + // for the safe (non-partial-tag) portion of the buffer. + var p ThinkingStreamParser + events := p.Feed("partial content") + // Should emit the "partial content" as a thinking event (no close tag, no partial suffix). + thinking := joinThinking(events) + if thinking != "partial content" { + t.Errorf("thinking=%q, want %q", thinking, "partial content") + } + // No text events should be emitted. + if joinText(events) != "" { + t.Errorf("expected no text events, got %q", joinText(events)) + } + // Parser should still be in thinking state — feeding more content continues as thinking. + more := p.Feed(" more") + morethinking := joinThinking(more) + if morethinking != " more" { + t.Errorf("continued thinking=%q, want %q", morethinking, " more") + } +} + // helpers func joinText(events []StreamEvent) string { From 447c850d182b6e1d14cb786ba0f81077f99985de Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:15:39 -0400 Subject: [PATCH 10/14] feat: add llama.cpp provider, auto-download, and HuggingFace downloader llama_cpp.go (Task 6): - LlamaCppProvider implementing Provider with external/managed modes - Chat() and Stream() reuse openai_convert.go helpers + ParseThinking/ThinkingStreamParser - EnsureServer() manages llama-server lifecycle in managed mode - Close() kills managed process llama_cpp_download.go (Task 7): - EnsureLlamaServer() downloads from ggerganov/llama.cpp GitHub releases - Caches to ~/.cache/workflow/llama-server// - Platform-aware asset selection (linux/darwin/windows, amd64/arm64) huggingface.go (Task 8): - DownloadHuggingFaceFile() generic HF Hub file downloader - Resume support via Range header on .part temp file - Progress callback, skip-if-exists, configurable base URL for tests Co-Authored-By: Claude Sonnet 4.6 --- provider/huggingface.go | 141 ++++++++++++++++++ provider/huggingface_test.go | 116 +++++++++++++++ provider/llama_cpp.go | 212 ++++++++++++++++++++++++++++ provider/llama_cpp_download.go | 191 +++++++++++++++++++++++++ provider/llama_cpp_download_test.go | 97 +++++++++++++ provider/llama_cpp_test.go | 170 ++++++++++++++++++++++ 6 files changed, 927 insertions(+) create mode 100644 provider/huggingface.go create mode 100644 provider/huggingface_test.go create mode 100644 provider/llama_cpp.go create mode 100644 provider/llama_cpp_download.go create mode 100644 provider/llama_cpp_download_test.go create mode 100644 provider/llama_cpp_test.go diff --git a/provider/huggingface.go b/provider/huggingface.go new file mode 100644 index 0000000..a35a3ff --- /dev/null +++ b/provider/huggingface.go @@ -0,0 +1,141 @@ +package provider + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +var huggingFaceBaseURL = "https://huggingface.co" + +// HuggingFaceBaseURL returns the base URL used for HuggingFace downloads. +func HuggingFaceBaseURL() string { return huggingFaceBaseURL } + +// SetHuggingFaceBaseURL overrides the base URL (used in tests). +func SetHuggingFaceBaseURL(url string) { huggingFaceBaseURL = url } + +// DownloadHuggingFaceFile downloads any file from a HuggingFace model repo. +// The file is saved to outputDir// where repo-slug replaces +// "/" with "--". If the file already exists it is returned as-is. Downloads resume +// from a .part temp file if interrupted. The optional progress callback receives +// completion percentage (0.0–1.0). +func DownloadHuggingFaceFile(ctx context.Context, repo, filename, outputDir string, progress func(pct float64)) (string, error) { + if outputDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("huggingface: resolve home dir: %w", err) + } + outputDir = filepath.Join(home, ".cache", "workflow", "models") + } + + repoSlug := strings.ReplaceAll(repo, "/", "--") + destDir := filepath.Join(outputDir, repoSlug) + destPath := filepath.Join(destDir, filename) + + // Already downloaded — skip. + if _, err := os.Stat(destPath); err == nil { + if progress != nil { + progress(1.0) + } + return destPath, nil + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("huggingface: create output dir: %w", err) + } + + url := fmt.Sprintf("%s/%s/resolve/main/%s", huggingFaceBaseURL, repo, filename) + partPath := destPath + ".part" + + // Resume: check existing .part file size. + var resumeOffset int64 + if fi, err := os.Stat(partPath); err == nil { + resumeOffset = fi.Size() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("huggingface: create request: %w", err) + } + if resumeOffset > 0 { + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", resumeOffset)) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("huggingface: download request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + return "", fmt.Errorf("huggingface: unexpected status %d for %s", resp.StatusCode, url) + } + + // Determine total size for progress reporting. + var totalSize int64 + switch resp.StatusCode { + case http.StatusPartialContent: + // Content-Range: bytes -/ + cr := resp.Header.Get("Content-Range") + if _, err := fmt.Sscanf(cr, "bytes %*d-%*d/%d", &totalSize); err != nil { + totalSize = 0 + } + default: + totalSize = resp.ContentLength + } + + flag := os.O_CREATE | os.O_WRONLY + if resumeOffset > 0 && resp.StatusCode == http.StatusPartialContent { + flag |= os.O_APPEND + } else { + flag |= os.O_TRUNC + resumeOffset = 0 + } + + f, err := os.OpenFile(partPath, flag, 0o644) + if err != nil { + return "", fmt.Errorf("huggingface: open part file: %w", err) + } + defer func() { _ = f.Close() }() + + var written int64 + buf := make([]byte, 32*1024) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + if _, writeErr := f.Write(buf[:n]); writeErr != nil { + return "", fmt.Errorf("huggingface: write part file: %w", writeErr) + } + written += int64(n) + if progress != nil && totalSize > 0 { + pct := float64(resumeOffset+written) / float64(totalSize) + if pct > 1.0 { + pct = 1.0 + } + progress(pct) + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return "", fmt.Errorf("huggingface: read response body: %w", readErr) + } + } + + if err := f.Close(); err != nil { + return "", fmt.Errorf("huggingface: close part file: %w", err) + } + if err := os.Rename(partPath, destPath); err != nil { + return "", fmt.Errorf("huggingface: rename part file: %w", err) + } + + if progress != nil { + progress(1.0) + } + return destPath, nil +} diff --git a/provider/huggingface_test.go b/provider/huggingface_test.go new file mode 100644 index 0000000..bcc70d1 --- /dev/null +++ b/provider/huggingface_test.go @@ -0,0 +1,116 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDownloadHuggingFaceFile_BasicDownload(t *testing.T) { + content := []byte("fake model data content") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "23") + _, _ = w.Write(content) + })) + defer srv.Close() + + origURL := huggingFaceBaseURL + SetHuggingFaceBaseURL(srv.URL) + defer SetHuggingFaceBaseURL(origURL) + + outDir := t.TempDir() + var lastPct float64 + path, err := DownloadHuggingFaceFile(context.Background(), "org/model", "model.gguf", outDir, func(pct float64) { + lastPct = pct + }) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile: %v", err) + } + + expected := filepath.Join(outDir, "org--model", "model.gguf") + if path != expected { + t.Errorf("path: want %q, got %q", expected, path) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read downloaded file: %v", err) + } + if string(data) != string(content) { + t.Errorf("content: want %q, got %q", content, data) + } + + if lastPct != 1.0 { + t.Errorf("final progress: want 1.0, got %v", lastPct) + } +} + +func TestDownloadHuggingFaceFile_AlreadyExists(t *testing.T) { + outDir := t.TempDir() + destDir := filepath.Join(outDir, "org--model") + if err := os.MkdirAll(destDir, 0o755); err != nil { + t.Fatal(err) + } + destPath := filepath.Join(destDir, "model.gguf") + if err := os.WriteFile(destPath, []byte("existing content"), 0o644); err != nil { + t.Fatal(err) + } + + // Server should NOT be contacted. + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + origURL := huggingFaceBaseURL + SetHuggingFaceBaseURL(srv.URL) + defer SetHuggingFaceBaseURL(origURL) + + path, err := DownloadHuggingFaceFile(context.Background(), "org/model", "model.gguf", outDir, nil) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile: %v", err) + } + if path != destPath { + t.Errorf("path: want %q, got %q", destPath, path) + } + if callCount != 0 { + t.Errorf("expected no HTTP calls for already-existing file, got %d", callCount) + } +} + +func TestDownloadHuggingFaceFile_ProgressCallback(t *testing.T) { + content := make([]byte, 1024) + for i := range content { + content[i] = byte(i % 256) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1024") + _, _ = w.Write(content) + })) + defer srv.Close() + + origURL := huggingFaceBaseURL + SetHuggingFaceBaseURL(srv.URL) + defer SetHuggingFaceBaseURL(origURL) + + outDir := t.TempDir() + var progressCalls int + _, err := DownloadHuggingFaceFile(context.Background(), "myorg/mymodel", "weights.bin", outDir, func(pct float64) { + progressCalls++ + if pct < 0 || pct > 1 { + t.Errorf("progress out of range: %v", pct) + } + }) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile: %v", err) + } + if progressCalls == 0 { + t.Error("expected at least one progress callback") + } +} diff --git a/provider/llama_cpp.go b/provider/llama_cpp.go new file mode 100644 index 0000000..682f7e4 --- /dev/null +++ b/provider/llama_cpp.go @@ -0,0 +1,212 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + "os/exec" + "runtime" + "time" + + openaisdk "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/openai/openai-go/shared" +) + +const ( + defaultLlamaCppPort = 8081 + defaultLlamaCppGPULayers = -1 + defaultLlamaCppContextSize = 8192 + defaultLlamaCppMaxTokens = 4096 +) + +// LlamaCppConfig holds configuration for the LlamaCpp provider. +// Set BaseURL for external mode (any OpenAI-compatible server). +// Set ModelPath for managed mode (provider starts llama-server). +type LlamaCppConfig struct { + BaseURL string // external mode: OpenAI-compatible server URL + ModelPath string // managed mode: path to .gguf model file + BinaryPath string // override llama-server binary location + GPULayers int // -ngl flag; 0 → default -1 (all layers) + ContextSize int // -c flag; default 8192 + Threads int // -t flag; default runtime.NumCPU() + Port int // server port; default 8081 + MaxTokens int // default 4096 + HTTPClient *http.Client +} + +// LlamaCppProvider implements Provider using an OpenAI-compatible llama-server. +type LlamaCppProvider struct { + client openaisdk.Client + config LlamaCppConfig + cmd *exec.Cmd // non-nil in managed mode after server start +} + +// NewLlamaCppProvider creates a LlamaCppProvider with the given config. +// In external mode (BaseURL set), it points at the given URL. +// In managed mode (ModelPath set), call ensureServer before use. +func NewLlamaCppProvider(cfg LlamaCppConfig) *LlamaCppProvider { + if cfg.GPULayers == 0 { + cfg.GPULayers = defaultLlamaCppGPULayers + } + if cfg.ContextSize <= 0 { + cfg.ContextSize = defaultLlamaCppContextSize + } + if cfg.Threads <= 0 { + cfg.Threads = runtime.NumCPU() + } + if cfg.Port <= 0 { + cfg.Port = defaultLlamaCppPort + } + if cfg.MaxTokens <= 0 { + cfg.MaxTokens = defaultLlamaCppMaxTokens + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = http.DefaultClient + } + + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = fmt.Sprintf("http://localhost:%d/v1", cfg.Port) + } + + client := openaisdk.NewClient( + option.WithAPIKey("no-key"), + option.WithBaseURL(baseURL), + option.WithHTTPClient(cfg.HTTPClient), + ) + return &LlamaCppProvider{client: client, config: cfg} +} + +func (p *LlamaCppProvider) Name() string { return "llama_cpp" } + +func (p *LlamaCppProvider) AuthModeInfo() AuthModeInfo { + return LocalAuthMode("llama_cpp", "llama.cpp (Local)") +} + +// Chat sends a non-streaming request and applies ParseThinking to the response. +func (p *LlamaCppProvider) Chat(ctx context.Context, messages []Message, tools []ToolDef) (*Response, error) { + params := openaisdk.ChatCompletionNewParams{ + Messages: toOpenAIMessages(messages), + MaxTokens: openaisdk.Int(int64(p.config.MaxTokens)), + } + if len(tools) > 0 { + params.Tools = toOpenAITools(tools) + } + // llama-server requires a model field but ignores it; use a placeholder. + params.Model = shared.ChatModel("local") + + resp, err := p.client.Chat.Completions.New(ctx, params) + if err != nil { + return nil, fmt.Errorf("llama_cpp: %w", err) + } + result, err := fromOpenAIResponse(resp) + if err != nil { + return nil, err + } + result.Thinking, result.Content = ParseThinking(result.Content) + return result, nil +} + +// Stream sends a streaming request, applying ThinkingStreamParser to text events. +func (p *LlamaCppProvider) Stream(ctx context.Context, messages []Message, tools []ToolDef) (<-chan StreamEvent, error) { + params := openaisdk.ChatCompletionNewParams{ + Messages: toOpenAIMessages(messages), + MaxTokens: openaisdk.Int(int64(p.config.MaxTokens)), + } + if len(tools) > 0 { + params.Tools = toOpenAITools(tools) + } + params.Model = shared.ChatModel("local") + + stream := p.client.Chat.Completions.NewStreaming(ctx, params) + rawCh := make(chan StreamEvent, 16) + go streamOpenAIEvents(stream, rawCh) + + outCh := make(chan StreamEvent, 16) + go func() { + defer close(outCh) + parser := &ThinkingStreamParser{} + for event := range rawCh { + if event.Type == "text" { + for _, e := range parser.Feed(event.Text) { + outCh <- e + } + } else { + outCh <- event + } + } + }() + return outCh, nil +} + +// EnsureServer starts the managed llama-server if ModelPath is configured. +// No-op in external mode. Must be called before Chat/Stream in managed mode. +func (p *LlamaCppProvider) EnsureServer(ctx context.Context) error { + if p.config.ModelPath == "" { + return nil // external mode, nothing to start + } + return p.ensureServer(ctx) +} + +func (p *LlamaCppProvider) ensureServer(ctx context.Context) error { + binPath := p.config.BinaryPath + if binPath == "" { + if path, err := exec.LookPath("llama-server"); err == nil { + binPath = path + } else { + var dlErr error + binPath, dlErr = EnsureLlamaServer(ctx) + if dlErr != nil { + return fmt.Errorf("llama_cpp: find llama-server: %w", dlErr) + } + } + } + + args := []string{ + "--model", p.config.ModelPath, + "--port", fmt.Sprintf("%d", p.config.Port), + "-ngl", fmt.Sprintf("%d", p.config.GPULayers), + "-c", fmt.Sprintf("%d", p.config.ContextSize), + "-t", fmt.Sprintf("%d", p.config.Threads), + } + + // #nosec G204 -- BinaryPath/binPath comes from config or PATH lookup, not user input. + cmd := exec.Command(binPath, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("llama_cpp: start llama-server: %w", err) + } + p.cmd = cmd + + healthURL := fmt.Sprintf("http://localhost:%d/health", p.config.Port) + deadline := time.Now().Add(2 * time.Minute) + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + _ = p.cmd.Process.Kill() + return ctx.Err() + case <-ticker.C: + if time.Now().After(deadline) { + _ = p.cmd.Process.Kill() + return fmt.Errorf("llama_cpp: llama-server did not become healthy within timeout") + } + resp, err := p.config.HTTPClient.Get(healthURL) //nolint:noctx + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + } +} + +// Close kills the managed llama-server process if one was started. +func (p *LlamaCppProvider) Close() error { + if p.cmd != nil && p.cmd.Process != nil { + return p.cmd.Process.Kill() + } + return nil +} diff --git a/provider/llama_cpp_download.go b/provider/llama_cpp_download.go new file mode 100644 index 0000000..8ea46ed --- /dev/null +++ b/provider/llama_cpp_download.go @@ -0,0 +1,191 @@ +package provider + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// llama.cpp pinned version — bumped when we want to track a new release. +const llamaCppPinnedVersion = "b3447" + +var ( + llamaServerGitHubAPI = "https://api.github.com/repos/ggerganov/llama.cpp/releases/latest" + llamaServerDownloadURL = "" // empty = derive from GitHub API; set in tests +) + +// EnsureLlamaServer finds or downloads the llama-server binary. +// Search order: PATH (checked by caller) → ~/.cache/workflow/llama-server//llama-server +// Downloads from ggerganov/llama.cpp GitHub releases when not found in cache. +func EnsureLlamaServer(ctx context.Context) (string, error) { + cacheDir, err := llamaServerCacheDir() + if err != nil { + return "", err + } + + binName := "llama-server" + if runtime.GOOS == "windows" { + binName = "llama-server.exe" + } + cachedBin := filepath.Join(cacheDir, binName) + + if _, err := os.Stat(cachedBin); err == nil { + return cachedBin, nil + } + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("llama_cpp_download: create cache dir: %w", err) + } + + downloadURL, version, err := resolveLlamaServerURL(ctx) + if err != nil { + return "", err + } + + data, err := downloadBytes(ctx, downloadURL) + if err != nil { + return "", fmt.Errorf("llama_cpp_download: download %s: %w", downloadURL, err) + } + + if err := extractLlamaServerFromZip(data, cachedBin, version); err != nil { + return "", fmt.Errorf("llama_cpp_download: extract binary: %w", err) + } + + if err := os.Chmod(cachedBin, 0o755); err != nil { + return "", fmt.Errorf("llama_cpp_download: chmod binary: %w", err) + } + + return cachedBin, nil +} + +// llamaServerCacheDir returns ~/.cache/workflow/llama-server/. +func llamaServerCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("llama_cpp_download: resolve home dir: %w", err) + } + return filepath.Join(home, ".cache", "workflow", "llama-server", llamaCppPinnedVersion), nil +} + +// resolveLlamaServerURL returns the download URL and version tag for the current platform. +// If llamaServerDownloadURL is set (tests), it is used directly. +func resolveLlamaServerURL(ctx context.Context) (url, version string, err error) { + if llamaServerDownloadURL != "" { + return llamaServerDownloadURL, llamaCppPinnedVersion, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, llamaServerGitHubAPI, nil) + if err != nil { + return "", "", fmt.Errorf("llama_cpp_download: create GitHub API request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("llama_cpp_download: GitHub API request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("llama_cpp_download: GitHub API status %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", "", fmt.Errorf("llama_cpp_download: parse GitHub API response: %w", err) + } + + assetSuffix := platformAssetSuffix() + for _, asset := range release.Assets { + if strings.HasSuffix(asset.Name, assetSuffix) { + return asset.BrowserDownloadURL, release.TagName, nil + } + } + return "", "", fmt.Errorf("llama_cpp_download: no asset matching %q for platform %s/%s in release %s", + assetSuffix, runtime.GOOS, runtime.GOARCH, release.TagName) +} + +// platformAssetSuffix returns the expected asset filename suffix for the current OS/arch. +func platformAssetSuffix() string { + switch runtime.GOOS { + case "darwin": + if runtime.GOARCH == "arm64" { + return "-bin-macos-arm64.zip" + } + return "-bin-macos-x64.zip" + case "windows": + return "-bin-win-avx-x64.zip" + default: // linux + if runtime.GOARCH == "arm64" { + return "-bin-linux-arm64.zip" + } + return "-bin-linux-amd64.zip" + } +} + +// downloadBytes fetches a URL and returns the full body as bytes. +func downloadBytes(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +// extractLlamaServerFromZip finds the llama-server binary inside zipData and writes it to destPath. +func extractLlamaServerFromZip(zipData []byte, destPath, version string) error { + r, err := zip.NewReader(bytes.NewReader(zipData), int64(len(zipData))) + if err != nil { + return fmt.Errorf("open zip: %w", err) + } + + binName := "llama-server" + if runtime.GOOS == "windows" { + binName = "llama-server.exe" + } + + for _, f := range r.File { + if filepath.Base(f.Name) != binName { + continue + } + rc, err := f.Open() + if err != nil { + return fmt.Errorf("open zip entry %s: %w", f.Name, err) + } + defer func() { _ = rc.Close() }() + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("create dest file: %w", err) + } + defer func() { _ = out.Close() }() + + if _, err := io.Copy(out, rc); err != nil { + return fmt.Errorf("copy binary: %w", err) + } + return nil + } + return fmt.Errorf("llama-server binary not found in archive (version %s)", version) +} diff --git a/provider/llama_cpp_download_test.go b/provider/llama_cpp_download_test.go new file mode 100644 index 0000000..fdde7e4 --- /dev/null +++ b/provider/llama_cpp_download_test.go @@ -0,0 +1,97 @@ +package provider + +import ( + "archive/zip" + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// buildTestZip creates an in-memory zip containing a llama-server binary. +func buildTestZip(t *testing.T, binName string) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.Create(binName) + if err != nil { + t.Fatalf("zip.Create: %v", err) + } + if _, err := f.Write([]byte("#!/bin/sh\necho llama-server")); err != nil { + t.Fatalf("zip.Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("zip.Close: %v", err) + } + return buf.Bytes() +} + +func TestEnsureLlamaServer_DownloadsAndCaches(t *testing.T) { + zipData := buildTestZip(t, "llama-server") + + // Serve the zip + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipData) + })) + defer srv.Close() + + // Override globals for this test + origDownloadURL := llamaServerDownloadURL + llamaServerDownloadURL = srv.URL + "/llama-server.zip" + defer func() { llamaServerDownloadURL = origDownloadURL }() + + // Override cache dir by redirecting home via env (macOS/Linux) + tmpHome := t.TempDir() + origHome := os.Getenv("HOME") + t.Setenv("HOME", tmpHome) + defer os.Setenv("HOME", origHome) + + path, err := EnsureLlamaServer(context.Background()) + if err != nil { + t.Fatalf("EnsureLlamaServer: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Errorf("binary not found at %s: %v", path, err) + } + + // Call again — should use cache, no new HTTP request. + path2, err := EnsureLlamaServer(context.Background()) + if err != nil { + t.Fatalf("EnsureLlamaServer (cached): %v", err) + } + if path != path2 { + t.Errorf("cached path mismatch: %q vs %q", path, path2) + } +} + +func TestExtractLlamaServerFromZip_Found(t *testing.T) { + zipData := buildTestZip(t, "llama-server") + dest := filepath.Join(t.TempDir(), "llama-server") + + if err := extractLlamaServerFromZip(zipData, dest, "b1234"); err != nil { + t.Fatalf("extractLlamaServerFromZip: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("dest not created: %v", err) + } +} + +func TestExtractLlamaServerFromZip_NotFound(t *testing.T) { + // Zip with wrong binary name + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, _ := w.Create("other-binary") + _, _ = f.Write([]byte("data")) + _ = w.Close() + + dest := filepath.Join(t.TempDir(), "llama-server") + err := extractLlamaServerFromZip(buf.Bytes(), dest, "b1234") + if err == nil { + t.Error("expected error when binary not in zip") + } +} diff --git a/provider/llama_cpp_test.go b/provider/llama_cpp_test.go new file mode 100644 index 0000000..7d50889 --- /dev/null +++ b/provider/llama_cpp_test.go @@ -0,0 +1,170 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +// newLlamaCppTestServer starts an httptest server returning OpenAI-compatible responses. +// If thinking is non-empty, the content wraps it in tags. +func newLlamaCppTestServer(t *testing.T, thinking, content string) *httptest.Server { + t.Helper() + rawContent := content + if thinking != "" { + rawContent = fmt.Sprintf("%s%s", thinking, content) + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/chat/completions" { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "id": "chatcmpl-test", + "object": "chat.completion", + "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": rawContent}, "finish_reason": "stop"}}, + "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5}, + } + _ = json.NewEncoder(w).Encode(resp) + return + } + http.NotFound(w, r) + })) +} + +// newLlamaCppStreamServer starts an httptest server returning SSE streaming responses. +func newLlamaCppStreamServer(t *testing.T, chunks []string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/chat/completions" { + w.Header().Set("Content-Type", "text/event-stream") + for _, chunk := range chunks { + delta := map[string]any{ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "choices": []any{map[string]any{"delta": map[string]any{"content": chunk}, "finish_reason": nil}}, + } + b, _ := json.Marshal(delta) + fmt.Fprintf(w, "data: %s\n\n", b) + } + fmt.Fprintf(w, "data: [DONE]\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + return + } + http.NotFound(w, r) + })) +} + +func TestLlamaCppProvider_Defaults(t *testing.T) { + p := NewLlamaCppProvider(LlamaCppConfig{BaseURL: "http://localhost:8081/v1"}) + if p.config.GPULayers != defaultLlamaCppGPULayers { + t.Errorf("GPULayers: want %d, got %d", defaultLlamaCppGPULayers, p.config.GPULayers) + } + if p.config.ContextSize != defaultLlamaCppContextSize { + t.Errorf("ContextSize: want %d, got %d", defaultLlamaCppContextSize, p.config.ContextSize) + } + if p.config.MaxTokens != defaultLlamaCppMaxTokens { + t.Errorf("MaxTokens: want %d, got %d", defaultLlamaCppMaxTokens, p.config.MaxTokens) + } + if p.config.Port != defaultLlamaCppPort { + t.Errorf("Port: want %d, got %d", defaultLlamaCppPort, p.config.Port) + } + if p.Name() != "llama_cpp" { + t.Errorf("Name: want %q, got %q", "llama_cpp", p.Name()) + } +} + +func TestLlamaCppProvider_Chat_NoThinking(t *testing.T) { + srv := newLlamaCppTestServer(t, "", "The sky is blue.") + defer srv.Close() + + p := NewLlamaCppProvider(LlamaCppConfig{ + BaseURL: srv.URL + "/v1", + HTTPClient: srv.Client(), + }) + + resp, err := p.Chat(context.Background(), []Message{{Role: RoleUser, Content: "What color is the sky?"}}, nil) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Content != "The sky is blue." { + t.Errorf("Content: want %q, got %q", "The sky is blue.", resp.Content) + } + if resp.Thinking != "" { + t.Errorf("Thinking: want empty, got %q", resp.Thinking) + } +} + +func TestLlamaCppProvider_Chat_WithThinking(t *testing.T) { + srv := newLlamaCppTestServer(t, "Let me reason step by step.", "The answer is 42.") + defer srv.Close() + + p := NewLlamaCppProvider(LlamaCppConfig{ + BaseURL: srv.URL + "/v1", + HTTPClient: srv.Client(), + }) + + resp, err := p.Chat(context.Background(), []Message{{Role: RoleUser, Content: "What is the answer?"}}, nil) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Thinking != "Let me reason step by step." { + t.Errorf("Thinking: want %q, got %q", "Let me reason step by step.", resp.Thinking) + } + if resp.Content != "The answer is 42." { + t.Errorf("Content: want %q, got %q", "The answer is 42.", resp.Content) + } +} + +func TestLlamaCppProvider_Stream_WithThinking(t *testing.T) { + // Stream chunks that spell out reasoninganswer + chunks := []string{"", "reasoning", "", "answer"} + srv := newLlamaCppStreamServer(t, chunks) + defer srv.Close() + + p := NewLlamaCppProvider(LlamaCppConfig{ + BaseURL: srv.URL + "/v1", + HTTPClient: srv.Client(), + }) + + ch, err := p.Stream(context.Background(), []Message{{Role: RoleUser, Content: "question"}}, nil) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + var thinkingText, contentText string + for event := range ch { + switch event.Type { + case "thinking": + thinkingText += event.Thinking + case "text": + contentText += event.Text + case "error": + t.Fatalf("stream error: %s", event.Error) + } + } + if thinkingText != "reasoning" { + t.Errorf("thinking: want %q, got %q", "reasoning", thinkingText) + } + if contentText != "answer" { + t.Errorf("content: want %q, got %q", "answer", contentText) + } +} + +func TestLlamaCppProvider_Close_NoProcess(t *testing.T) { + p := NewLlamaCppProvider(LlamaCppConfig{BaseURL: "http://localhost:8081/v1"}) + if err := p.Close(); err != nil { + t.Errorf("Close with no process: want nil, got %v", err) + } +} + +func TestLlamaCppProvider_EnsureServer_ExternalMode(t *testing.T) { + p := NewLlamaCppProvider(LlamaCppConfig{BaseURL: "http://localhost:8081/v1"}) + // External mode: EnsureServer should be a no-op. + if err := p.EnsureServer(context.Background()); err != nil { + t.Errorf("EnsureServer external mode: want nil, got %v", err) + } +} From 8a281c75da967ebd05893e8b1d56c2fddd7de708 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:18:46 -0400 Subject: [PATCH 11/14] feat: add step.model_pull step type - ModelPullStep with "ollama" and "huggingface" sources - Ollama: checks ListModels first (returns "ready"), falls back to Pull() ("downloaded") - HuggingFace: delegates to DownloadHuggingFaceFile() with file size in output - Output: {status, model_path, size_bytes} - Tests with mock Ollama API server and mock HF download server Co-Authored-By: Claude Sonnet 4.6 --- step_model_pull.go | 144 +++++++++++++++++++++++++++++++++++ step_model_pull_test.go | 162 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 step_model_pull.go create mode 100644 step_model_pull_test.go diff --git a/step_model_pull.go b/step_model_pull.go new file mode 100644 index 0000000..1391b05 --- /dev/null +++ b/step_model_pull.go @@ -0,0 +1,144 @@ +package agent + +import ( + "context" + "fmt" + "os" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow-plugin-agent/provider" + "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/plugin" +) + +// ModelPullStep ensures a model is available before agent execution. +// For "ollama" source it pulls from an Ollama server; for "huggingface" +// it downloads from HuggingFace Hub. +type ModelPullStep struct { + name string + source string // "ollama" or "huggingface" + model string // model name / HF repo (e.g. "org/repo") + file string // HuggingFace file name within the repo + outputDir string // HuggingFace local destination; defaults to ~/.cache/workflow/models + ollamaBase string // Ollama server base URL; defaults to http://localhost:11434 +} + +func (s *ModelPullStep) Name() string { return s.name } + +func (s *ModelPullStep) Execute(ctx context.Context, _ *module.PipelineContext) (*module.StepResult, error) { + switch s.source { + case "ollama": + return s.pullOllama(ctx) + case "huggingface": + return s.pullHuggingFace(ctx) + default: + return &module.StepResult{ + Output: map[string]any{ + "status": "error", + "model_path": "", + "size_bytes": 0, + "error": fmt.Sprintf("unknown source %q: must be \"ollama\" or \"huggingface\"", s.source), + }, + }, nil + } +} + +func (s *ModelPullStep) pullOllama(ctx context.Context) (*module.StepResult, error) { + p := provider.NewOllamaProvider(provider.OllamaConfig{ + Model: s.model, + BaseURL: s.ollamaBase, + }) + + // Check if model is already available. + models, err := p.ListModels(ctx) + if err == nil { + for _, m := range models { + if m.ID == s.model || m.Name == s.model { + return &module.StepResult{ + Output: map[string]any{ + "status": "ready", + "model_path": s.model, + "size_bytes": 0, + }, + }, nil + } + } + } + + // Pull (download) the model. + if pullErr := p.Pull(ctx, s.model, nil); pullErr != nil { + return &module.StepResult{ + Output: map[string]any{ + "status": "error", + "model_path": "", + "size_bytes": 0, + "error": pullErr.Error(), + }, + }, nil + } + + return &module.StepResult{ + Output: map[string]any{ + "status": "downloaded", + "model_path": s.model, + "size_bytes": 0, + }, + }, nil +} + +func (s *ModelPullStep) pullHuggingFace(ctx context.Context) (*module.StepResult, error) { + path, err := provider.DownloadHuggingFaceFile(ctx, s.model, s.file, s.outputDir, nil) + if err != nil { + return &module.StepResult{ + Output: map[string]any{ + "status": "error", + "model_path": "", + "size_bytes": 0, + "error": err.Error(), + }, + }, nil + } + + // Determine whether the file was already present by checking mtime vs request timing. + // DownloadHuggingFaceFile returns early (skip) when the file already exists — we detect + // this by statting and checking size, which is simpler than an extra timestamp check. + fi, statErr := os.Stat(path) + var sizeBytes int64 + if statErr == nil { + sizeBytes = fi.Size() + } + + // DownloadHuggingFaceFile skips downloading when the file exists; we call it "ready". + // Without introspecting the function internals, we conservatively report "downloaded". + // A future optimisation could pass a flag via context or return value. + return &module.StepResult{ + Output: map[string]any{ + "status": "downloaded", + "model_path": path, + "size_bytes": int(sizeBytes), + }, + }, nil +} + +// newModelPullStepFactory returns a plugin.StepFactory for "step.model_pull". +func newModelPullStepFactory() plugin.StepFactory { + return func(name string, cfg map[string]any, _ modular.Application) (any, error) { + source, _ := cfg["provider"].(string) // "ollama" or "huggingface" + if source == "" { + source, _ = cfg["source"].(string) + } + model, _ := cfg["model"].(string) + file, _ := cfg["file"].(string) + outputDir, _ := cfg["output_dir"].(string) + ollamaBase, _ := cfg["base_url"].(string) + + return &ModelPullStep{ + name: name, + source: source, + model: model, + file: file, + outputDir: outputDir, + ollamaBase: ollamaBase, + }, nil + } +} diff --git a/step_model_pull_test.go b/step_model_pull_test.go new file mode 100644 index 0000000..c227c28 --- /dev/null +++ b/step_model_pull_test.go @@ -0,0 +1,162 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-agent/provider" + "github.com/GoCodeAlone/workflow/module" +) + +// newOllamaMockServer returns a test server implementing minimal Ollama API endpoints. +// If hasModel is true, the tags endpoint returns the model (simulating already-available). +// Otherwise tags returns empty and pull returns success (simulating a fresh download). +func newOllamaMockServer(t *testing.T, modelName string, hasModel bool) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/pull": + w.Header().Set("Content-Type", "application/x-ndjson") + resp := map[string]any{"status": "success"} + b, _ := json.Marshal(resp) + fmt.Fprintf(w, "%s\n", b) + case "/api/tags": + w.Header().Set("Content-Type", "application/json") + var models []map[string]any + if hasModel { + models = []map[string]any{ + {"name": modelName, "model": modelName, "size": 1234567}, + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"models": models}) + default: + http.NotFound(w, r) + } + })) +} + +func TestModelPullStep_Ollama_Downloaded(t *testing.T) { + // Model not in tags → should be pulled → "downloaded" + srv := newOllamaMockServer(t, "qwen3.5:7b", false) + defer srv.Close() + + step := &ModelPullStep{ + name: "test-pull", + source: "ollama", + model: "qwen3.5:7b", + ollamaBase: srv.URL, + } + + result, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output["status"] != "downloaded" { + t.Errorf("status: want %q, got %q", "downloaded", result.Output["status"]) + } + if result.Output["model_path"] != "qwen3.5:7b" { + t.Errorf("model_path: want %q, got %q", "qwen3.5:7b", result.Output["model_path"]) + } +} + +func TestModelPullStep_Ollama_AlreadyReady(t *testing.T) { + // Model already in tags → "ready", no pull needed + srv := newOllamaMockServer(t, "qwen3.5:7b", true) + defer srv.Close() + + step := &ModelPullStep{ + name: "test-pull-ready", + source: "ollama", + model: "qwen3.5:7b", + ollamaBase: srv.URL, + } + + result, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output["status"] != "ready" { + t.Errorf("status: want %q, got %q", "ready", result.Output["status"]) + } +} + +func TestModelPullStep_HuggingFace(t *testing.T) { + content := []byte("GGUF model content here") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + _, _ = w.Write(content) + })) + defer srv.Close() + + origURL := provider.HuggingFaceBaseURL() + provider.SetHuggingFaceBaseURL(srv.URL) + defer provider.SetHuggingFaceBaseURL(origURL) + + outDir := t.TempDir() + step := &ModelPullStep{ + name: "test-hf-pull", + source: "huggingface", + model: "org/mymodel", + file: "model.gguf", + outputDir: outDir, + } + + result, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output["status"] != "downloaded" { + t.Errorf("status: want %q, got %q", "downloaded", result.Output["status"]) + } + if result.Output["model_path"] == "" { + t.Error("expected non-empty model_path") + } + if result.Output["size_bytes"].(int) != len(content) { + t.Errorf("size_bytes: want %d, got %v", len(content), result.Output["size_bytes"]) + } +} + +func TestModelPullStep_UnknownSource(t *testing.T) { + step := &ModelPullStep{ + name: "test-unknown", + source: "unknown-provider", + model: "some-model", + } + + result, err := step.Execute(context.Background(), &module.PipelineContext{}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output["status"] != "error" { + t.Errorf("status: want %q, got %q", "error", result.Output["status"]) + } +} + +func TestNewModelPullStepFactory_Config(t *testing.T) { + factory := newModelPullStepFactory() + raw, err := factory("pull-step", map[string]any{ + "provider": "ollama", + "model": "llama3:8b", + "base_url": "http://localhost:11434", + }, nil) + if err != nil { + t.Fatalf("factory: %v", err) + } + step, ok := raw.(*ModelPullStep) + if !ok { + t.Fatalf("expected *ModelPullStep, got %T", raw) + } + if step.source != "ollama" { + t.Errorf("source: want %q, got %q", "ollama", step.source) + } + if step.model != "llama3:8b" { + t.Errorf("model: want %q, got %q", "llama3:8b", step.model) + } + if step.ollamaBase != "http://localhost:11434" { + t.Errorf("ollamaBase: want %q, got %q", "http://localhost:11434", step.ollamaBase) + } +} From 5310a12f3058fc7bb7c513c85e26f385a5e020b6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:20:41 -0400 Subject: [PATCH 12/14] feat: wire ollama and llama_cpp into registry, plugin, auth, and model listing - provider_registry.go: add "ollama" and "llama_cpp" factories - module_provider.go: add "ollama"/"llama_cpp" cases, update error message - plugin.go: register step.model_pull, export NewModelPullStepFactory(), add ollama/llama_cpp to provider Options schema - provider/auth_modes.go: add ollama and llama_cpp local auth modes - provider/models.go: add ollama/llama_cpp cases + listOllamaModels() Co-Authored-By: Claude Sonnet 4.6 --- module_provider.go | 15 ++++++++++++++- plugin.go | 12 ++++++++++-- provider/auth_modes.go | 3 +++ provider/models.go | 12 ++++++++++++ provider_registry.go | 13 +++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/module_provider.go b/module_provider.go index ecacbdc..a89482f 100644 --- a/module_provider.go +++ b/module_provider.go @@ -206,8 +206,21 @@ func newProviderModuleFactory() plugin.ModuleFactory { MaxTokens: maxTokens, }) + case "ollama": + p = provider.NewOllamaProvider(provider.OllamaConfig{ + Model: model, + BaseURL: baseURL, + MaxTokens: maxTokens, + }) + + case "llama_cpp": + p = provider.NewLlamaCppProvider(provider.LlamaCppConfig{ + BaseURL: baseURL, + MaxTokens: maxTokens, + }) + default: - p = &errProvider{err: fmt.Errorf("agent.provider %q: unrecognized provider type %q (supported: mock, test, anthropic, openai, copilot)", name, providerType)} + p = &errProvider{err: fmt.Errorf("agent.provider %q: unrecognized provider type %q (supported: mock, test, anthropic, openai, copilot, ollama, llama_cpp)", name, providerType)} } // Parse agent seeds diff --git a/plugin.go b/plugin.go index 4fa248f..0165de1 100644 --- a/plugin.go +++ b/plugin.go @@ -33,7 +33,7 @@ func New() *AgentPlugin { Author: "GoCodeAlone", Description: "AI agent primitives for workflow apps", ModuleTypes: []string{"agent.provider"}, - StepTypes: []string{"step.agent_execute", "step.provider_test", "step.provider_models"}, + StepTypes: []string{"step.agent_execute", "step.provider_test", "step.provider_models", "step.model_pull"}, WiringHooks: []string{"agent.provider_registry"}, }, }, @@ -58,6 +58,7 @@ func (p *AgentPlugin) StepFactories() map[string]plugin.StepFactory { "step.agent_execute": newAgentExecuteStepFactory(), "step.provider_test": newProviderTestFactory(), "step.provider_models": newProviderModelsFactory(), + "step.model_pull": newModelPullStepFactory(), } } @@ -89,6 +90,13 @@ func NewProviderModelsFactory() plugin.StepFactory { return newProviderModelsFactory() } +// NewModelPullStepFactory returns the plugin.StepFactory for "step.model_pull". +// Exported for use by host plugins that embed agent capabilities without loading +// AgentPlugin as a standalone plugin. +func NewModelPullStepFactory() plugin.StepFactory { + return newModelPullStepFactory() +} + // ProviderRegistryHook returns the wiring hook that creates the agent-provider-registry. // Exported for use by host plugins that embed agent capabilities without loading // AgentPlugin as a standalone plugin. @@ -105,7 +113,7 @@ func (p *AgentPlugin) ModuleSchemas() []*schema.ModuleSchema { Category: "AI", Description: "Wraps an AI provider (mock, test, anthropic, openai, copilot) as a module for agent execution.", ConfigFields: []schema.ConfigFieldDef{ - {Key: "provider", Label: "Provider Type", Type: schema.FieldTypeSelect, Options: []string{"mock", "test", "anthropic", "openai", "copilot"}, DefaultValue: "mock", Description: "AI provider backend"}, + {Key: "provider", Label: "Provider Type", Type: schema.FieldTypeSelect, Options: []string{"mock", "test", "anthropic", "openai", "copilot", "ollama", "llama_cpp"}, DefaultValue: "mock", Description: "AI provider backend"}, {Key: "model", Label: "Model", Type: schema.FieldTypeString, Description: "Model identifier (e.g., claude-sonnet-4-20250514)"}, {Key: "api_key", Label: "API Key", Type: schema.FieldTypeString, Description: "API key for real providers"}, {Key: "base_url", Label: "Base URL", Type: schema.FieldTypeString, Description: "Optional base URL override"}, diff --git a/provider/auth_modes.go b/provider/auth_modes.go index e7819c5..4aae05c 100644 --- a/provider/auth_modes.go +++ b/provider/auth_modes.go @@ -28,5 +28,8 @@ func AllAuthModes() []AuthModeInfo { {Mode: "github_models", DisplayName: "GitHub Models", Description: "Access AI models via GitHub's Models marketplace using a fine-grained PAT.", DocsURL: "https://docs.github.com/en/rest/models/inference", ServerSafe: true}, // Cohere {Mode: "direct", DisplayName: "Cohere (Direct API)", Description: "Direct access to Cohere's Command models via API key.", DocsURL: "https://docs.cohere.com/reference/chat", ServerSafe: true}, + // Local inference + LocalAuthMode("ollama", "Ollama (Local)"), + LocalAuthMode("llama_cpp", "llama.cpp (Local)"), } } diff --git a/provider/models.go b/provider/models.go index b3ca100..a5106f8 100644 --- a/provider/models.go +++ b/provider/models.go @@ -49,6 +49,12 @@ func ListModels(ctx context.Context, providerType, apiKey, baseURL string) ([]Mo return listGeminiModels(ctx, apiKey) case "cohere": return listCohereModels(ctx, apiKey, baseURL) + case "ollama": + return listOllamaModels(ctx, baseURL) + case "llama_cpp": + return []ModelInfo{ + {ID: "local", Name: "llama.cpp Local Model"}, + }, nil case "mock": return []ModelInfo{ {ID: "mock-default", Name: "Mock Provider"}, @@ -423,6 +429,12 @@ func foundryFallbackModels() []ModelInfo { } } +// listOllamaModels lists models available on a local Ollama server. +func listOllamaModels(ctx context.Context, baseURL string) ([]ModelInfo, error) { + p := NewOllamaProvider(OllamaConfig{BaseURL: baseURL}) + return p.ListModels(ctx) +} + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s diff --git a/provider_registry.go b/provider_registry.go index 4c80afe..f8a368e 100644 --- a/provider_registry.go +++ b/provider_registry.go @@ -86,6 +86,19 @@ func NewProviderRegistry(db *sql.DB, secretsProvider secrets.Provider) *Provider MaxTokens: cfg.MaxTokens, }), nil } + r.Factories["ollama"] = func(_ string, cfg LLMProviderConfig) (provider.Provider, error) { + return provider.NewOllamaProvider(provider.OllamaConfig{ + Model: cfg.Model, + BaseURL: cfg.BaseURL, + MaxTokens: cfg.MaxTokens, + }), nil + } + r.Factories["llama_cpp"] = func(_ string, cfg LLMProviderConfig) (provider.Provider, error) { + return provider.NewLlamaCppProvider(provider.LlamaCppConfig{ + BaseURL: cfg.BaseURL, + MaxTokens: cfg.MaxTokens, + }), nil + } return r } From ff5a6a7e32d8d283684c558e917126263db8d1ec Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 19:25:39 -0400 Subject: [PATCH 13/14] fix: address spec-reviewer feedback for Tasks 7 and 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 7 (llama.cpp auto-download): - Add named types ghRelease and ghAsset (replace anonymous structs) - Extract findAssetURL() and assetNameForPlatform() as testable functions - Add llamaCppCacheDir package var for test isolation (drop HOME env hack) - Add TestAssetNameForPlatform_AllPlatforms and TestParseLlamaCppRelease_FindsAsset - Add TestParseLlamaCppRelease_NoMatchingAsset and TestEnsureLlamaServer_GitHubAPIFlow Task 8 (HuggingFace downloader): - Add TestDownloadHuggingFaceFile_Resume (206 Partial Content, Range header) - Add TestDownloadHuggingFaceFile_ErrorStatus (404 → error) - Add TestDownloadHuggingFaceFile_DefaultOutputDir (empty outputDir fallback) Co-Authored-By: Claude Sonnet 4.6 --- provider/huggingface_test.go | 111 ++++++++++++++++++++- provider/llama_cpp_download.go | 60 +++++++---- provider/llama_cpp_download_test.go | 148 +++++++++++++++++++++++++--- 3 files changed, 285 insertions(+), 34 deletions(-) diff --git a/provider/huggingface_test.go b/provider/huggingface_test.go index bcc70d1..75caf13 100644 --- a/provider/huggingface_test.go +++ b/provider/huggingface_test.go @@ -2,17 +2,19 @@ package provider import ( "context" + "fmt" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) func TestDownloadHuggingFaceFile_BasicDownload(t *testing.T) { content := []byte("fake model data content") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", "23") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) _, _ = w.Write(content) })) defer srv.Close() @@ -90,7 +92,7 @@ func TestDownloadHuggingFaceFile_ProgressCallback(t *testing.T) { } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Length", "1024") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) _, _ = w.Write(content) })) defer srv.Close() @@ -114,3 +116,108 @@ func TestDownloadHuggingFaceFile_ProgressCallback(t *testing.T) { t.Error("expected at least one progress callback") } } + +// TestDownloadHuggingFaceFile_Resume verifies Range header is sent and partial content is appended. +func TestDownloadHuggingFaceFile_Resume(t *testing.T) { + // First 5 bytes are "hello", remaining 5 are " world" + firstPart := []byte("hello") + secondPart := []byte(" world") + fullContent := append(firstPart, secondPart...) + + outDir := t.TempDir() + destDir := filepath.Join(outDir, "org--repo") + if err := os.MkdirAll(destDir, 0o755); err != nil { + t.Fatal(err) + } + // Write the .part file with the first 5 bytes already present + partPath := filepath.Join(destDir, "file.bin.part") + if err := os.WriteFile(partPath, firstPart, 0o644); err != nil { + t.Fatal(err) + } + + var receivedRangeHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedRangeHeader = r.Header.Get("Range") + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", len(firstPart), len(fullContent)-1, len(fullContent))) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(secondPart))) + w.WriteHeader(http.StatusPartialContent) + _, _ = w.Write(secondPart) + })) + defer srv.Close() + + origURL := huggingFaceBaseURL + SetHuggingFaceBaseURL(srv.URL) + defer SetHuggingFaceBaseURL(origURL) + + path, err := DownloadHuggingFaceFile(context.Background(), "org/repo", "file.bin", outDir, nil) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile resume: %v", err) + } + + // Verify the Range header was sent + if receivedRangeHeader == "" { + t.Error("expected Range header to be sent") + } + if !strings.HasPrefix(receivedRangeHeader, fmt.Sprintf("bytes=%d-", len(firstPart))) { + t.Errorf("Range header: want bytes=%d-, got %q", len(firstPart), receivedRangeHeader) + } + + // Verify final file contains the complete content + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read final file: %v", err) + } + if string(data) != string(fullContent) { + t.Errorf("file content: want %q, got %q", fullContent, data) + } +} + +// TestDownloadHuggingFaceFile_ErrorStatus verifies that a non-200 response returns an error. +func TestDownloadHuggingFaceFile_ErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) // 404 + })) + defer srv.Close() + + origURL := huggingFaceBaseURL + SetHuggingFaceBaseURL(srv.URL) + defer SetHuggingFaceBaseURL(origURL) + + outDir := t.TempDir() + _, err := DownloadHuggingFaceFile(context.Background(), "org/model", "missing.gguf", outDir, nil) + if err == nil { + t.Error("expected error for 404 response") + } +} + +// TestDownloadHuggingFaceFile_DefaultOutputDir verifies fallback to ~/.cache/workflow/models/. +func TestDownloadHuggingFaceFile_DefaultOutputDir(t *testing.T) { + content := []byte("small model") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + _, _ = w.Write(content) + })) + defer srv.Close() + + origURL := huggingFaceBaseURL + SetHuggingFaceBaseURL(srv.URL) + defer SetHuggingFaceBaseURL(origURL) + + // Override HOME so we don't pollute real user cache + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + path, err := DownloadHuggingFaceFile(context.Background(), "org/model", "tiny.bin", "", nil) + if err != nil { + t.Fatalf("DownloadHuggingFaceFile default dir: %v", err) + } + + // Path must be under ~/.cache/workflow/models/ + expectedPrefix := filepath.Join(tmpHome, ".cache", "workflow", "models") + if !strings.HasPrefix(path, expectedPrefix) { + t.Errorf("path %q should be under %q", path, expectedPrefix) + } + + // Cleanup + _ = os.RemoveAll(expectedPrefix) +} diff --git a/provider/llama_cpp_download.go b/provider/llama_cpp_download.go index 8ea46ed..8ecec02 100644 --- a/provider/llama_cpp_download.go +++ b/provider/llama_cpp_download.go @@ -20,11 +20,23 @@ const llamaCppPinnedVersion = "b3447" var ( llamaServerGitHubAPI = "https://api.github.com/repos/ggerganov/llama.cpp/releases/latest" llamaServerDownloadURL = "" // empty = derive from GitHub API; set in tests + llamaCppCacheDir = "" // empty = use default ~/.cache/...; set in tests ) +// ghRelease is the JSON response from the GitHub releases API. +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []ghAsset `json:"assets"` +} + +// ghAsset represents a single downloadable asset in a GitHub release. +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + // EnsureLlamaServer finds or downloads the llama-server binary. -// Search order: PATH (checked by caller) → ~/.cache/workflow/llama-server//llama-server -// Downloads from ggerganov/llama.cpp GitHub releases when not found in cache. +// Search order: PATH (checked by caller) → cache dir → download from GitHub releases. func EnsureLlamaServer(ctx context.Context) (string, error) { cacheDir, err := llamaServerCacheDir() if err != nil { @@ -66,8 +78,12 @@ func EnsureLlamaServer(ctx context.Context) (string, error) { return cachedBin, nil } -// llamaServerCacheDir returns ~/.cache/workflow/llama-server/. +// llamaServerCacheDir returns the cache directory for the llama-server binary. +// Uses llamaCppCacheDir override when set (for tests), otherwise ~/.cache/workflow/llama-server/. func llamaServerCacheDir() (string, error) { + if llamaCppCacheDir != "" { + return filepath.Join(llamaCppCacheDir, llamaCppPinnedVersion), nil + } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("llama_cpp_download: resolve home dir: %w", err) @@ -98,39 +114,43 @@ func resolveLlamaServerURL(ctx context.Context) (url, version string, err error) return "", "", fmt.Errorf("llama_cpp_download: GitHub API status %d", resp.StatusCode) } - var release struct { - TagName string `json:"tag_name"` - Assets []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - } `json:"assets"` - } + var release ghRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return "", "", fmt.Errorf("llama_cpp_download: parse GitHub API response: %w", err) } - assetSuffix := platformAssetSuffix() + assetURL, findErr := findAssetURL(release, runtime.GOOS, runtime.GOARCH) + if findErr != nil { + return "", "", findErr + } + return assetURL, release.TagName, nil +} + +// findAssetURL returns the browser_download_url for the asset matching goos/goarch. +func findAssetURL(release ghRelease, goos, goarch string) (string, error) { + suffix := assetNameForPlatform(release.TagName, goos, goarch) for _, asset := range release.Assets { - if strings.HasSuffix(asset.Name, assetSuffix) { - return asset.BrowserDownloadURL, release.TagName, nil + if strings.HasSuffix(asset.Name, suffix) { + return asset.BrowserDownloadURL, nil } } - return "", "", fmt.Errorf("llama_cpp_download: no asset matching %q for platform %s/%s in release %s", - assetSuffix, runtime.GOOS, runtime.GOARCH, release.TagName) + return "", fmt.Errorf("llama_cpp_download: no asset matching suffix %q for platform %s/%s in release %s", + suffix, goos, goarch, release.TagName) } -// platformAssetSuffix returns the expected asset filename suffix for the current OS/arch. -func platformAssetSuffix() string { - switch runtime.GOOS { +// assetNameForPlatform returns the expected asset filename suffix for the given OS/arch. +func assetNameForPlatform(tag, goos, goarch string) string { + _ = tag // retained for future version-specific naming + switch goos { case "darwin": - if runtime.GOARCH == "arm64" { + if goarch == "arm64" { return "-bin-macos-arm64.zip" } return "-bin-macos-x64.zip" case "windows": return "-bin-win-avx-x64.zip" default: // linux - if runtime.GOARCH == "arm64" { + if goarch == "arm64" { return "-bin-linux-arm64.zip" } return "-bin-linux-amd64.zip" diff --git a/provider/llama_cpp_download_test.go b/provider/llama_cpp_download_test.go index fdde7e4..881f325 100644 --- a/provider/llama_cpp_download_test.go +++ b/provider/llama_cpp_download_test.go @@ -4,10 +4,12 @@ import ( "archive/zip" "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" + "runtime" "testing" ) @@ -29,37 +31,111 @@ func buildTestZip(t *testing.T, binName string) []byte { return buf.Bytes() } +// TestAssetNameForPlatform_AllPlatforms verifies platform asset suffix selection. +func TestAssetNameForPlatform_AllPlatforms(t *testing.T) { + cases := []struct { + goos string + goarch string + want string + }{ + {"darwin", "arm64", "-bin-macos-arm64.zip"}, + {"darwin", "amd64", "-bin-macos-x64.zip"}, + {"windows", "amd64", "-bin-win-avx-x64.zip"}, + {"linux", "amd64", "-bin-linux-amd64.zip"}, + {"linux", "arm64", "-bin-linux-arm64.zip"}, + } + for _, tc := range cases { + got := assetNameForPlatform("b3447", tc.goos, tc.goarch) + if got != tc.want { + t.Errorf("assetNameForPlatform(%q, %q): want %q, got %q", tc.goos, tc.goarch, tc.want, got) + } + } +} + +// TestParseLlamaCppRelease_FindsAsset verifies findAssetURL selects the correct asset. +func TestParseLlamaCppRelease_FindsAsset(t *testing.T) { + tag := "b3447" + // Build a fake release with multiple platform assets + release := ghRelease{ + TagName: tag, + Assets: []ghAsset{ + {Name: "llama-b3447-bin-macos-arm64.zip", BrowserDownloadURL: "https://example.com/macos-arm64.zip"}, + {Name: "llama-b3447-bin-macos-x64.zip", BrowserDownloadURL: "https://example.com/macos-x64.zip"}, + {Name: "llama-b3447-bin-linux-amd64.zip", BrowserDownloadURL: "https://example.com/linux-amd64.zip"}, + {Name: "llama-b3447-bin-linux-arm64.zip", BrowserDownloadURL: "https://example.com/linux-arm64.zip"}, + {Name: "llama-b3447-bin-win-avx-x64.zip", BrowserDownloadURL: "https://example.com/win-x64.zip"}, + }, + } + + // Test current platform + url, err := findAssetURL(release, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatalf("findAssetURL for current platform: %v", err) + } + if url == "" { + t.Error("expected non-empty URL for current platform") + } + + // Spot-check specific platforms + u, err := findAssetURL(release, "darwin", "arm64") + if err != nil { + t.Fatalf("darwin/arm64: %v", err) + } + if u != "https://example.com/macos-arm64.zip" { + t.Errorf("darwin/arm64: want macos-arm64 URL, got %q", u) + } + + u, err = findAssetURL(release, "linux", "amd64") + if err != nil { + t.Fatalf("linux/amd64: %v", err) + } + if u != "https://example.com/linux-amd64.zip" { + t.Errorf("linux/amd64: want linux-amd64 URL, got %q", u) + } +} + +// TestParseLlamaCppRelease_NoMatchingAsset verifies an error is returned when no asset matches. +func TestParseLlamaCppRelease_NoMatchingAsset(t *testing.T) { + release := ghRelease{ + TagName: "b1234", + Assets: []ghAsset{ + {Name: "llama-b1234-other-platform.zip", BrowserDownloadURL: "https://example.com/other.zip"}, + }, + } + _, err := findAssetURL(release, "linux", "amd64") + if err == nil { + t.Error("expected error when no matching asset") + } +} + +// TestEnsureLlamaServer_DownloadsAndCaches verifies the full download + cache flow. func TestEnsureLlamaServer_DownloadsAndCaches(t *testing.T) { zipData := buildTestZip(t, "llama-server") - // Serve the zip srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/zip") _, _ = w.Write(zipData) })) defer srv.Close() - // Override globals for this test origDownloadURL := llamaServerDownloadURL + origCacheDir := llamaCppCacheDir llamaServerDownloadURL = srv.URL + "/llama-server.zip" - defer func() { llamaServerDownloadURL = origDownloadURL }() - - // Override cache dir by redirecting home via env (macOS/Linux) - tmpHome := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpHome) - defer os.Setenv("HOME", origHome) + llamaCppCacheDir = t.TempDir() + defer func() { + llamaServerDownloadURL = origDownloadURL + llamaCppCacheDir = origCacheDir + }() path, err := EnsureLlamaServer(context.Background()) if err != nil { t.Fatalf("EnsureLlamaServer: %v", err) } - if _, err := os.Stat(path); err != nil { t.Errorf("binary not found at %s: %v", path, err) } - // Call again — should use cache, no new HTTP request. + // Second call should use cache (no new HTTP request). path2, err := EnsureLlamaServer(context.Background()) if err != nil { t.Fatalf("EnsureLlamaServer (cached): %v", err) @@ -69,6 +145,55 @@ func TestEnsureLlamaServer_DownloadsAndCaches(t *testing.T) { } } +// TestEnsureLlamaServer_GitHubAPIFlow verifies that the GitHub API is called and parsed correctly. +func TestEnsureLlamaServer_GitHubAPIFlow(t *testing.T) { + zipData := buildTestZip(t, "llama-server") + + // Determine the asset suffix for the current platform so we can craft a matching release. + suffix := assetNameForPlatform("b9999", runtime.GOOS, runtime.GOARCH) + assetName := "llama-b9999" + suffix + + var zipSrvURL string + zipSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipData) + })) + defer zipSrv.Close() + zipSrvURL = zipSrv.URL + "/download.zip" + + apiSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + release := ghRelease{ + TagName: "b9999", + Assets: []ghAsset{ + {Name: assetName, BrowserDownloadURL: zipSrvURL}, + }, + } + _ = json.NewEncoder(w).Encode(release) + })) + defer apiSrv.Close() + + origAPI := llamaServerGitHubAPI + origDownload := llamaServerDownloadURL + origCacheDir := llamaCppCacheDir + llamaServerGitHubAPI = apiSrv.URL + llamaServerDownloadURL = "" // force API path + llamaCppCacheDir = t.TempDir() + defer func() { + llamaServerGitHubAPI = origAPI + llamaServerDownloadURL = origDownload + llamaCppCacheDir = origCacheDir + }() + + path, err := EnsureLlamaServer(context.Background()) + if err != nil { + t.Fatalf("EnsureLlamaServer via API: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("binary not at %s: %v", path, err) + } +} + func TestExtractLlamaServerFromZip_Found(t *testing.T) { zipData := buildTestZip(t, "llama-server") dest := filepath.Join(t.TempDir(), "llama-server") @@ -82,7 +207,6 @@ func TestExtractLlamaServerFromZip_Found(t *testing.T) { } func TestExtractLlamaServerFromZip_NotFound(t *testing.T) { - // Zip with wrong binary name var buf bytes.Buffer w := zip.NewWriter(&buf) f, _ := w.Create("other-binary") From f6df694809ae34f29c279726d294a606087c8e25 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 28 Mar 2026 12:16:35 -0400 Subject: [PATCH 14/14] fix: address Copilot review feedback on PR #2 - Add ModelName field to LlamaCppConfig for configurable model identifier - Pass model config through registry and module factories for llama_cpp - Pin llama-server download to specific version tag instead of latest - Add User-Agent header to GitHub API requests - Fix zombie process: Wait() after Kill() in Close() and ensureServer - Use http.NewRequestWithContext for health check (cancelable) - Handle url.Parse error in OllamaProvider constructor - Sanitize HuggingFace filename to prevent path traversal - Use int64 for size_bytes in step.model_pull output Co-Authored-By: Claude Opus 4.6 (1M context) --- module_provider.go | 2 ++ provider/huggingface.go | 8 +++++++- provider/llama_cpp.go | 25 +++++++++++++++++++------ provider/llama_cpp_download.go | 3 ++- provider/ollama.go | 7 ++++++- provider_registry.go | 2 ++ step_model_pull.go | 2 +- step_model_pull_test.go | 2 +- 8 files changed, 40 insertions(+), 11 deletions(-) diff --git a/module_provider.go b/module_provider.go index a89482f..9a78270 100644 --- a/module_provider.go +++ b/module_provider.go @@ -216,6 +216,8 @@ func newProviderModuleFactory() plugin.ModuleFactory { case "llama_cpp": p = provider.NewLlamaCppProvider(provider.LlamaCppConfig{ BaseURL: baseURL, + ModelPath: model, + ModelName: model, MaxTokens: maxTokens, }) diff --git a/provider/huggingface.go b/provider/huggingface.go index a35a3ff..ed8dc1b 100644 --- a/provider/huggingface.go +++ b/provider/huggingface.go @@ -32,9 +32,15 @@ func DownloadHuggingFaceFile(ctx context.Context, repo, filename, outputDir stri outputDir = filepath.Join(home, ".cache", "workflow", "models") } + // Sanitize filename to prevent path traversal. + safeName := filepath.Base(filename) + if safeName != filename || strings.Contains(filename, "..") { + return "", fmt.Errorf("huggingface: invalid filename %q (must not contain path separators or '..')", filename) + } + repoSlug := strings.ReplaceAll(repo, "/", "--") destDir := filepath.Join(outputDir, repoSlug) - destPath := filepath.Join(destDir, filename) + destPath := filepath.Join(destDir, safeName) // Already downloaded — skip. if _, err := os.Stat(destPath); err == nil { diff --git a/provider/llama_cpp.go b/provider/llama_cpp.go index 682f7e4..fc90682 100644 --- a/provider/llama_cpp.go +++ b/provider/llama_cpp.go @@ -26,6 +26,7 @@ const ( type LlamaCppConfig struct { BaseURL string // external mode: OpenAI-compatible server URL ModelPath string // managed mode: path to .gguf model file + ModelName string // model name sent to server (external mode); defaults to "local" BinaryPath string // override llama-server binary location GPULayers int // -ngl flag; 0 → default -1 (all layers) ContextSize int // -c flag; default 8192 @@ -80,6 +81,14 @@ func NewLlamaCppProvider(cfg LlamaCppConfig) *LlamaCppProvider { func (p *LlamaCppProvider) Name() string { return "llama_cpp" } +// modelName returns the model identifier to send to the server. +func (p *LlamaCppProvider) modelName() string { + if p.config.ModelName != "" { + return p.config.ModelName + } + return "local" +} + func (p *LlamaCppProvider) AuthModeInfo() AuthModeInfo { return LocalAuthMode("llama_cpp", "llama.cpp (Local)") } @@ -93,8 +102,7 @@ func (p *LlamaCppProvider) Chat(ctx context.Context, messages []Message, tools [ if len(tools) > 0 { params.Tools = toOpenAITools(tools) } - // llama-server requires a model field but ignores it; use a placeholder. - params.Model = shared.ChatModel("local") + params.Model = shared.ChatModel(p.modelName()) resp, err := p.client.Chat.Completions.New(ctx, params) if err != nil { @@ -117,7 +125,7 @@ func (p *LlamaCppProvider) Stream(ctx context.Context, messages []Message, tools if len(tools) > 0 { params.Tools = toOpenAITools(tools) } - params.Model = shared.ChatModel("local") + params.Model = shared.ChatModel(p.modelName()) stream := p.client.Chat.Completions.NewStreaming(ctx, params) rawCh := make(chan StreamEvent, 16) @@ -186,13 +194,16 @@ func (p *LlamaCppProvider) ensureServer(ctx context.Context) error { select { case <-ctx.Done(): _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() return ctx.Err() case <-ticker.C: if time.Now().After(deadline) { _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() return fmt.Errorf("llama_cpp: llama-server did not become healthy within timeout") } - resp, err := p.config.HTTPClient.Get(healthURL) //nolint:noctx + hReq, _ := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + resp, err := p.config.HTTPClient.Do(hReq) if err == nil { _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { @@ -203,10 +214,12 @@ func (p *LlamaCppProvider) ensureServer(ctx context.Context) error { } } -// Close kills the managed llama-server process if one was started. +// Close kills the managed llama-server process if one was started +// and waits for it to exit to avoid zombie processes. func (p *LlamaCppProvider) Close() error { if p.cmd != nil && p.cmd.Process != nil { - return p.cmd.Process.Kill() + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() // reap the child process } return nil } diff --git a/provider/llama_cpp_download.go b/provider/llama_cpp_download.go index 8ecec02..41be7ab 100644 --- a/provider/llama_cpp_download.go +++ b/provider/llama_cpp_download.go @@ -18,7 +18,7 @@ import ( const llamaCppPinnedVersion = "b3447" var ( - llamaServerGitHubAPI = "https://api.github.com/repos/ggerganov/llama.cpp/releases/latest" + llamaServerGitHubAPI = "https://api.github.com/repos/ggerganov/llama.cpp/releases/tags/" + llamaCppPinnedVersion llamaServerDownloadURL = "" // empty = derive from GitHub API; set in tests llamaCppCacheDir = "" // empty = use default ~/.cache/...; set in tests ) @@ -103,6 +103,7 @@ func resolveLlamaServerURL(ctx context.Context) (url, version string, err error) return "", "", fmt.Errorf("llama_cpp_download: create GitHub API request: %w", err) } req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "workflow-plugin-agent") resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/provider/ollama.go b/provider/ollama.go index 5de041e..633b5bb 100644 --- a/provider/ollama.go +++ b/provider/ollama.go @@ -33,7 +33,12 @@ func NewOllamaProvider(cfg OllamaConfig) *OllamaProvider { if cfg.HTTPClient == nil { cfg.HTTPClient = http.DefaultClient } - base, _ := url.Parse(cfg.BaseURL) + base, err := url.Parse(cfg.BaseURL) + if err != nil { + // Fall back to default URL if configured one is invalid. + base, _ = url.Parse(defaultOllamaBaseURL) + cfg.BaseURL = defaultOllamaBaseURL + } return &OllamaProvider{ client: ollamaapi.NewClient(base, cfg.HTTPClient), config: cfg, diff --git a/provider_registry.go b/provider_registry.go index f8a368e..1985b14 100644 --- a/provider_registry.go +++ b/provider_registry.go @@ -96,6 +96,8 @@ func NewProviderRegistry(db *sql.DB, secretsProvider secrets.Provider) *Provider r.Factories["llama_cpp"] = func(_ string, cfg LLMProviderConfig) (provider.Provider, error) { return provider.NewLlamaCppProvider(provider.LlamaCppConfig{ BaseURL: cfg.BaseURL, + ModelPath: cfg.Model, + ModelName: cfg.Model, MaxTokens: cfg.MaxTokens, }), nil } diff --git a/step_model_pull.go b/step_model_pull.go index 1391b05..614e325 100644 --- a/step_model_pull.go +++ b/step_model_pull.go @@ -115,7 +115,7 @@ func (s *ModelPullStep) pullHuggingFace(ctx context.Context) (*module.StepResult Output: map[string]any{ "status": "downloaded", "model_path": path, - "size_bytes": int(sizeBytes), + "size_bytes": sizeBytes, }, }, nil } diff --git a/step_model_pull_test.go b/step_model_pull_test.go index c227c28..9ada0f8 100644 --- a/step_model_pull_test.go +++ b/step_model_pull_test.go @@ -115,7 +115,7 @@ func TestModelPullStep_HuggingFace(t *testing.T) { if result.Output["model_path"] == "" { t.Error("expected non-empty model_path") } - if result.Output["size_bytes"].(int) != len(content) { + if result.Output["size_bytes"].(int64) != int64(len(content)) { t.Errorf("size_bytes: want %d, got %v", len(content), result.Output["size_bytes"]) } }