From e6cc9562f972e2f2ca2aad98627ce35ee8e2f60e Mon Sep 17 00:00:00 2001 From: Gerard Louis Recinto Date: Thu, 8 Oct 2026 13:54:00 -0700 Subject: [PATCH] gemma example: local agent loop with Gemma over Ollama, Joltrin vector recall, and the verification barrier --- .github/workflows/gemma-example.yml | 33 +++ examples/README.md | 7 + examples/gemma_ollama/main.go | 436 ++++++++++++++++++++++++++++ examples/gemma_ollama/main_test.go | 254 ++++++++++++++++ 4 files changed, 730 insertions(+) create mode 100644 .github/workflows/gemma-example.yml create mode 100644 examples/gemma_ollama/main.go create mode 100644 examples/gemma_ollama/main_test.go diff --git a/.github/workflows/gemma-example.yml b/.github/workflows/gemma-example.yml new file mode 100644 index 000000000..377adcb0d --- /dev/null +++ b/.github/workflows/gemma-example.yml @@ -0,0 +1,33 @@ +name: Gemma example + +# Runs the offline tests for examples/gemma_ollama. They use a scripted Ollama +# server and the real Joltrin vector store and verify barrier, so no model is +# needed. The live test against gemma3:4b is skipped unless JOLTRIN_OLLAMA_LIVE=1. +# Not a required check, so it can use a path filter. +on: + pull_request: + paths: + - 'examples/gemma_ollama/**' + - 'verify/**' + - 'ai/vector/**' + - 'ai/embed/ollama.go' + - '.github/workflows/gemma-example.yml' + +permissions: + contents: read + +jobs: + gemma-ollama: + name: Gemma agent loop, offline + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + cache: true + + - run: go vet ./examples/gemma_ollama + + - run: go test ./examples/gemma_ollama -race -count=1 -v diff --git a/examples/README.md b/examples/README.md index 72eafc0ce..e4f8f7ee8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -55,6 +55,13 @@ A LangGraph `StateGraph` that calls the verification barrier through `sop-mcp-se - **Models**: Claude through `langchain-anthropic` when `ANTHROPIC_API_KEY` is set, otherwise a scripted policy (not an LLM) so it runs offline. - **Check**: `python examples/langgraph_agent/test_agent.py` +### 8. Local agent with Gemma over Ollama (`gemma_ollama`) +Gemma (`gemma3:4b`) proposes actions and `embeddinggemma` provides the vectors. Joltrin retrieves context from an embedded vector store, runs the verification barrier, and commits each accepted action in a transaction. Everything stays in one Go process with no remote database. +- **Key Feature**: app-level schema check, then `verify.Workflow.CheckSafety`, then an ACID commit. A model that asks to publish before a human approval is blocked on every attempt and writes nothing. +- **Why**: Small open-weight models send malformed or out-of-order actions. Splitting payload validation (app code) from ordering rules (the barrier) keeps each layer simple. +- **Run**: `ollama pull gemma3:4b && ollama pull embeddinggemma`, then `go run ./examples/gemma_ollama` +- **Check**: `go test ./examples/gemma_ollama` (offline). Add `JOLTRIN_OLLAMA_LIVE=1 -run TestLiveGemma` to run against your local Ollama. + --- ## ▶️ Running the Examples diff --git a/examples/gemma_ollama/main.go b/examples/gemma_ollama/main.go new file mode 100644 index 000000000..658c3ad8d --- /dev/null +++ b/examples/gemma_ollama/main.go @@ -0,0 +1,436 @@ +// Package main runs a fully local agent loop: Gemma (through Ollama) proposes +// actions, and Joltrin retrieves context, enforces workflow ordering, and +// commits state, all in one Go process. +// +// The model only proposes. Every action goes through three gates before it can +// change state: +// +// 1. App schema check: valid JSON, known fields, an allowed step, bounded payload. +// 2. verify barrier: the step's preconditions and the safety rules must hold. +// 3. ACID transaction: the write and its embedding commit together or not at all. +// +// The model is never offered human_approve. Only the application calls it, so a +// model cannot approve its own work. +// +// Prerequisites and run: +// +// ollama pull gemma3:4b && ollama pull embeddinggemma +// go run ./examples/gemma_ollama +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/sharedcode/joltrin/ai" + "github.com/sharedcode/joltrin/ai/database" + "github.com/sharedcode/joltrin/ai/embed" + "github.com/sharedcode/joltrin/ai/vector" + sop "github.com/sharedcode/joltrin/v5" + coredb "github.com/sharedcode/joltrin/v5/database" + "github.com/sharedcode/joltrin/v5/verify" +) + +const ( + factsStore = "facts" + logStore = "agent_log" + + maxNoteBytes = 2000 + systemPrompt = `You are a release assistant. Reply with JSON only, in this exact shape: +{"step": "", "note": ""} +Use draft_note to write a note. Use publish_note only when the task says to publish.` +) + +// ollamaChat is a minimal client for Ollama's /api/chat endpoint. +type ollamaChat struct { + baseURL string + model string + http *http.Client +} + +func (c *ollamaChat) chat(ctx context.Context, system, user string) (string, error) { + body, err := json.Marshal(map[string]any{ + "model": c.model, + "stream": false, + "format": "json", // Ollama constrains the reply to valid JSON + "options": map[string]any{"temperature": 0, "num_ctx": 4096}, + "messages": []map[string]string{ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + }, + }) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/chat", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("ollama chat: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) + return "", fmt.Errorf("ollama chat: status %d: %s", resp.StatusCode, b) + } + var out struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", fmt.Errorf("ollama chat: decode: %w", err) + } + return out.Message.Content, nil +} + +// action is what the model is asked to produce. +type action struct { + Step string `json:"step"` + Note string `json:"note"` +} + +// parseAction is the app-level schema check. It runs before the barrier and +// rejects anything the barrier should never have to reason about. +func parseAction(raw string, wf *verify.Workflow, allowed map[string]bool) (action, error) { + var a action + dec := json.NewDecoder(strings.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil { + return a, fmt.Errorf("malformed action: %w", err) + } + if dec.More() { + return a, errors.New("malformed action: trailing data after JSON object") + } + if _, ok := wf.Steps[verify.StepID(a.Step)]; !ok { + return a, fmt.Errorf("unknown step %q", a.Step) + } + if !allowed[a.Step] { + return a, fmt.Errorf("step %q is not available to you", a.Step) + } + if n := len(a.Note); n == 0 || n > maxNoteBytes { + return a, fmt.Errorf("note must be 1..%d bytes", maxNoteBytes) + } + return a, nil +} + +// newWorkflow declares ordering invariants only. Payload validation lives in +// parseAction, not here. +func newWorkflow() (*verify.Workflow, error) { + return verify.NewWorkflow( + []verify.Step{ + {ID: "draft_note", Establishes: []verify.State{"drafted"}}, + {ID: "human_approve", Requires: []verify.State{"drafted"}, Establishes: []verify.State{"approved"}}, + {ID: "publish_note", Requires: []verify.State{"approved"}, Establishes: []verify.State{"published"}}, + }, + []verify.SafetyRule{ + {Name: "no-publish-without-approval", Forbidden: "published", Requires: "approved"}, + }, + []verify.ReachabilityRule{ + {Name: "publish-stays-reachable", Target: "published"}, + }, + ) +} + +type agent struct { + db *database.Database + embedder *embed.OllamaEmbedder + llm *ollamaChat + wf *verify.Workflow + trace *verify.Trace + allowed map[string]bool // steps the model may propose + maxRetries int + out io.Writer +} + +// outcome reports how a run ended. Committed is false when every attempt was +// rejected, in which case nothing was written. +type outcome struct { + Committed bool + Step string + Attempts int + Reason string // last rejection, empty when committed +} + +func (a *agent) logf(format string, args ...any) { fmt.Fprintf(a.out, format+"\n", args...) } + +// ingest embeds the facts first (slow, outside any lock), then writes them in a +// single transaction. +func (a *agent) ingest(ctx context.Context, facts map[string]string) error { + ids := make([]string, 0, len(facts)) + texts := make([]string, 0, len(facts)) + for id, t := range facts { + ids, texts = append(ids, id), append(texts, t) + } + vecs, err := a.embedder.EmbedTexts(ctx, texts) + if err != nil { + return fmt.Errorf("embed facts: %w", err) + } + items := make([]ai.Item[map[string]any], len(ids)) + for i := range ids { + items[i] = ai.Item[map[string]any]{ID: ids[i], Vector: vecs[i], Payload: map[string]any{"text": texts[i]}} + } + tx, err := a.db.BeginTransaction(ctx, sop.ForWriting) + if err != nil { + return err + } + store, err := a.db.OpenVectorStore(ctx, factsStore, tx, vector.Config{UsageMode: ai.Dynamic}) + if err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := store.UpsertBatch(ctx, items); err != nil { + _ = tx.Rollback(ctx) + return err + } + return tx.Commit(ctx) +} + +// recall returns the k facts closest to the query. +func (a *agent) recall(ctx context.Context, query string, k int) ([]ai.Hit[map[string]any], error) { + vecs, err := a.embedder.EmbedTexts(ctx, []string{query}) + if err != nil { + return nil, fmt.Errorf("embed query: %w", err) + } + tx, err := a.db.BeginTransaction(ctx, sop.ForReading) + if err != nil { + return nil, err + } + store, err := a.db.OpenVectorStore(ctx, factsStore, tx, vector.Config{}) + if err != nil { + _ = tx.Rollback(ctx) + return nil, err + } + hits, err := store.Query(ctx, vecs[0], k, nil) + if err != nil { + _ = tx.Rollback(ctx) + return nil, err + } + return hits, tx.Commit(ctx) +} + +// logCount reports how many actions have been committed. +func (a *agent) logCount(ctx context.Context) (int64, error) { + tx, err := a.db.BeginTransaction(ctx, sop.ForReading) + if err != nil { + return 0, err + } + store, err := a.db.OpenVectorStore(ctx, logStore, tx, vector.Config{}) + if err != nil { + _ = tx.Rollback(ctx) + return 0, err + } + n, err := store.Count(ctx) + if err != nil { + _ = tx.Rollback(ctx) + return 0, err + } + return n, tx.Commit(ctx) +} + +// approve is the human gate. Only the application calls it. +func (a *agent) approve() error { + return a.wf.CheckAndCommit(a.trace, "human_approve") +} + +// run asks the model for an action and retries on rejection. Nothing is written +// unless an action passes the schema check and the barrier. +func (a *agent) run(ctx context.Context, runID, task, knowledge string) (outcome, error) { + user := "Context:\n" + knowledge + "\nTask: " + task + var res outcome + for attempt := 0; attempt <= a.maxRetries; attempt++ { + res.Attempts = attempt + 1 + raw, err := a.llm.chat(ctx, systemPrompt, user) + if err != nil { + return res, err + } + act, err := parseAction(raw, a.wf, a.allowed) + if err != nil { + res.Reason = err.Error() + a.logf(" attempt %d rejected by schema check: %s", attempt+1, res.Reason) + user += "\nYour last reply was rejected: " + res.Reason + ". Reply with valid JSON." + continue + } + err = a.commit(ctx, act, fmt.Sprintf("%s-%d", runID, attempt)) + if err == nil { + res.Committed, res.Step, res.Reason = true, act.Step, "" + a.logf(" attempt %d committed step %q", attempt+1, act.Step) + return res, nil + } + var v *verify.Violation + if !errors.As(err, &v) { + return res, err + } + res.Reason = a.wf.WhyBlocked(v) + a.logf(" attempt %d blocked by barrier: %s", attempt+1, res.Reason) + user += "\nBlocked: " + res.Reason + ". " + a.wf.NextAction(v) + } + return res, nil +} + +// commit is the only path that changes state. Cheap rejections come first: the +// read-only barrier check runs before any embedding or transaction work. +func (a *agent) commit(ctx context.Context, act action, idemKey string) error { + step := verify.StepID(act.Step) + if err := a.wf.CheckSafety(a.trace, step); err != nil { + return err + } + vecs, err := a.embedder.EmbedTexts(ctx, []string{act.Note}) + if err != nil { + return fmt.Errorf("embed note: %w", err) + } + tx, err := a.db.BeginTransaction(ctx, sop.ForWriting) + if err != nil { + return err + } + store, err := a.db.OpenVectorStore(ctx, logStore, tx, vector.Config{UsageMode: ai.Dynamic}) + if err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := store.Upsert(ctx, ai.Item[map[string]any]{ + ID: fmt.Sprintf("act-%d", time.Now().UnixNano()), + Vector: vecs[0], + Payload: map[string]any{"step": act.Step, "note": act.Note}, + }); err != nil { + _ = tx.Rollback(ctx) + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + // The data is durable; now record the step. The key makes a replayed commit + // of the same attempt a no-op. If the process dies between the two commits + // the row exists but the trace has no entry, which fails closed: later steps + // stay blocked until the step is recorded again. + _, err = a.wf.CheckAndCommitIdempotent(a.trace, step, idemKey) + return err +} + +func newAgent(db *database.Database, baseURL, chatModel, embedModel string, out io.Writer) (*agent, error) { + wf, err := newWorkflow() + if err != nil { + return nil, err + } + return &agent{ + db: db, + embedder: embed.NewOllama(baseURL, embedModel), + llm: &ollamaChat{baseURL: baseURL, model: chatModel, http: &http.Client{Timeout: 3 * time.Minute}}, + wf: wf, + trace: verify.NewTrace(), + allowed: map[string]bool{"draft_note": true, "publish_note": true}, + maxRetries: 2, + out: out, + }, nil +} + +var releaseFacts = map[string]string{ + "f1": "Backups run nightly at 02:00 UTC and are retained for 30 days.", + "f2": "Production deploys require two approvals and a green canary.", + "f3": "Rollbacks are done by re-pointing the release symlink to the previous build.", +} + +func main() { + baseURL := flag.String("ollama", "http://localhost:11434", "Ollama base URL") + chatModel := flag.String("chat-model", "gemma3:4b", "Gemma chat model") + embedModel := flag.String("embed-model", "embeddinggemma", "embedding model (768 dims)") + flag.Parse() + + if err := demo(context.Background(), *baseURL, *chatModel, *embedModel, os.Stdout); err != nil { + log.Fatal(err) + } +} + +// demo runs three acts against a throwaway database: +// 1. Gemma drafts a note from retrieved facts: committed. +// 2. Gemma is told to publish before approval: the barrier blocks every attempt. +// 3. A human approves, then publishing succeeds. +func demo(ctx context.Context, baseURL, chatModel, embedModel string, out io.Writer) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + dir, err := os.MkdirTemp("", "joltrin-gemma-*") + if err != nil { + return err + } + defer os.RemoveAll(dir) + db := database.NewDatabase(coredb.DatabaseOptions{StoresFolders: []string{dir}}) + + a, err := newAgent(db, baseURL, chatModel, embedModel, out) + if err != nil { + return err + } + if err := a.ingest(ctx, releaseFacts); err != nil { + return err + } + a.logf("ingested %d facts with %s", len(releaseFacts), embedModel) + + question := "How do I roll back a bad release?" + hits, err := a.recall(ctx, question, 2) + if err != nil { + return err + } + var knowledge strings.Builder + for _, h := range hits { + fmt.Fprintf(&knowledge, "- %v\n", h.Payload["text"]) + a.logf("recall %s score=%.3f", h.ID, h.Score) + } + + a.logf("\nact 1: draft a note") + r1, err := a.run(ctx, "act1", question+" Write the answer as a note.", knowledge.String()) + if err != nil { + return err + } + if !r1.Committed { + return fmt.Errorf("act 1: no valid action after %d attempts: %s", r1.Attempts, r1.Reason) + } + + a.logf("\nact 2: publish before approval") + before, err := a.logCount(ctx) + if err != nil { + return err + } + r2, err := a.run(ctx, "act2", "Publish the release note now.", knowledge.String()) + if err != nil { + return err + } + after, err := a.logCount(ctx) + if err != nil { + return err + } + a.logf(" committed=%v rows before=%d after=%d", r2.Committed, before, after) + if r2.Committed || after != before { + return errors.New("act 2: barrier let an unapproved publish through") + } + + a.logf("\nact 3: human approves, then publish") + if err := a.approve(); err != nil { + return err + } + r3, err := a.run(ctx, "act3", "Publish the release note now.", knowledge.String()) + if err != nil { + return err + } + if !r3.Committed || r3.Step != "publish_note" { + return fmt.Errorf("act 3: expected publish_note to commit, got %+v", r3) + } + n, err := a.logCount(ctx) + if err != nil { + return err + } + a.logf(" log rows: %d", n) + return nil +} diff --git a/examples/gemma_ollama/main_test.go b/examples/gemma_ollama/main_test.go new file mode 100644 index 000000000..a0a29e340 --- /dev/null +++ b/examples/gemma_ollama/main_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "math" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + + "github.com/sharedcode/joltrin/ai/database" + coredb "github.com/sharedcode/joltrin/v5/database" +) + +// fakeOllama serves /api/chat from a script and /api/embeddings from a keyword +// embedding, so the tests exercise the real HTTP client and the real Joltrin +// store without a model. +type fakeOllama struct { + srv *httptest.Server + mu sync.Mutex + replies []string // chat replies, consumed in order; the last one repeats + chats int +} + +var keywordAxes = []string{"backup", "deploy", "rollback", "release", "publish", "note"} + +func keywordVec(text string) []float64 { + t := strings.ToLower(text) + v := make([]float64, len(keywordAxes)) + var norm float64 + for i, k := range keywordAxes { + if strings.Contains(t, k) { + v[i] = 1 + norm++ + } + } + if norm == 0 { + v[0] = 1 + norm = 1 + } + for i := range v { + v[i] /= math.Sqrt(norm) + } + return v +} + +func newFakeOllama(t *testing.T, replies ...string) *fakeOllama { + t.Helper() + f := &fakeOllama{replies: replies} + mux := http.NewServeMux() + mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + i := f.chats + if i >= len(f.replies) { + i = len(f.replies) - 1 + } + f.chats++ + reply := f.replies[i] + f.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]string{"content": reply}}) + }) + mux.HandleFunc("/api/embeddings", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Prompt string `json:"prompt"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + _ = json.NewEncoder(w).Encode(map[string]any{"embedding": keywordVec(req.Prompt)}) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func newTestAgent(t *testing.T, baseURL string) *agent { + t.Helper() + db := database.NewDatabase(coredb.DatabaseOptions{StoresFolders: []string{t.TempDir()}}) + a, err := newAgent(db, baseURL, "test-chat", "test-embed", io.Discard) + if err != nil { + t.Fatal(err) + } + return a +} + +func TestParseAction(t *testing.T) { + wf, err := newWorkflow() + if err != nil { + t.Fatal(err) + } + allowed := map[string]bool{"draft_note": true, "publish_note": true} + tests := []struct { + name string + raw string + wantErr string + }{ + {"valid", `{"step":"draft_note","note":"hello"}`, ""}, + {"not json", `draft a note please`, "malformed"}, + {"unknown field", `{"step":"draft_note","note":"x","extra":1}`, "malformed"}, + {"trailing data", `{"step":"draft_note","note":"x"} {"step":"publish_note","note":"y"}`, "trailing"}, + {"unknown step", `{"step":"drop_prod_db","note":"x"}`, "unknown step"}, + {"step not offered to model", `{"step":"human_approve","note":"x"}`, "not available"}, + {"empty note", `{"step":"draft_note","note":""}`, "note must be"}, + {"oversized note", `{"step":"draft_note","note":"` + strings.Repeat("a", maxNoteBytes+1) + `"}`, "note must be"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := parseAction(tc.raw, wf, allowed) + switch { + case tc.wantErr == "" && err != nil: + t.Fatalf("unexpected error: %v", err) + case tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)): + t.Fatalf("error = %v, want containing %q", err, tc.wantErr) + } + }) + } +} + +func TestRecallRanksRelevantFact(t *testing.T) { + f := newFakeOllama(t, `{}`) + a := newTestAgent(t, f.srv.URL) + ctx := context.Background() + if err := a.ingest(ctx, releaseFacts); err != nil { + t.Fatal(err) + } + hits, err := a.recall(ctx, "How do I rollback a bad release?", 2) + if err != nil { + t.Fatal(err) + } + if len(hits) == 0 || hits[0].ID != "f3" { + t.Fatalf("top hit = %+v, want f3", hits) + } +} + +func TestSchemaRejectionThenRecovery(t *testing.T) { + f := newFakeOllama(t, + `not json at all`, + `{"step":"drop_prod_db","note":"x"}`, + `{"step":"draft_note","note":"Re-point the release symlink to roll back."}`, + ) + a := newTestAgent(t, f.srv.URL) + ctx := context.Background() + + res, err := a.run(ctx, "r", "write a note", "") + if err != nil { + t.Fatal(err) + } + if !res.Committed || res.Step != "draft_note" || res.Attempts != 3 { + t.Fatalf("result = %+v, want draft_note committed on attempt 3", res) + } + if n, _ := a.logCount(ctx); n != 1 { + t.Fatalf("log rows = %d, want 1", n) + } +} + +func TestBarrierBlocksPublishUntilHumanApproves(t *testing.T) { + f := newFakeOllama(t, `{"step":"publish_note","note":"Release notes go out now."}`) + a := newTestAgent(t, f.srv.URL) + ctx := context.Background() + + // A draft exists, but nobody approved it: every attempt is blocked and + // nothing is written. + if _, err := a.wf.CheckAndCommitIdempotent(a.trace, "draft_note", "seed"); err != nil { + t.Fatal(err) + } + res, err := a.run(ctx, "blocked", "publish", "") + if err != nil { + t.Fatal(err) + } + if res.Committed || res.Attempts != a.maxRetries+1 { + t.Fatalf("result = %+v, want blocked after %d attempts", res, a.maxRetries+1) + } + if !strings.Contains(res.Reason, "approved") { + t.Fatalf("reason %q should name the missing state", res.Reason) + } + if n, _ := a.logCount(ctx); n != 0 { + t.Fatalf("blocked run wrote %d rows, want 0", n) + } + + if err := a.approve(); err != nil { + t.Fatal(err) + } + res, err = a.run(ctx, "approved", "publish", "") + if err != nil { + t.Fatal(err) + } + if !res.Committed || res.Step != "publish_note" { + t.Fatalf("result = %+v, want publish_note committed", res) + } + if n, _ := a.logCount(ctx); n != 1 { + t.Fatalf("log rows = %d, want 1", n) + } +} + +func TestModelCannotApproveItself(t *testing.T) { + f := newFakeOllama(t, `{"step":"human_approve","note":"I approve my own work."}`) + a := newTestAgent(t, f.srv.URL) + if _, err := a.wf.CheckAndCommitIdempotent(a.trace, "draft_note", "seed"); err != nil { + t.Fatal(err) + } + res, err := a.run(context.Background(), "self", "approve", "") + if err != nil { + t.Fatal(err) + } + if res.Committed { + t.Fatalf("model approved its own work: %+v", res) + } + if err := a.wf.CheckSafety(a.trace, "publish_note"); err == nil { + t.Fatal("publish_note became allowed without a human approval") + } +} + +func TestDemoWithScriptedModel(t *testing.T) { + f := newFakeOllama(t, + `{"step":"draft_note","note":"Rollback by re-pointing the release symlink."}`, + `{"step":"publish_note","note":"publish"}`, + `{"step":"publish_note","note":"publish"}`, + `{"step":"publish_note","note":"publish"}`, + `{"step":"publish_note","note":"Release note published."}`, + ) + var out strings.Builder + if err := demo(context.Background(), f.srv.URL, "test-chat", "test-embed", &out); err != nil { + t.Fatalf("demo: %v\n%s", err, out.String()) + } + for _, want := range []string{"recall f3", "blocked by barrier", "committed=false rows before=1 after=1", `committed step "publish_note"`} { + if !strings.Contains(out.String(), want) { + t.Errorf("demo output missing %q:\n%s", want, out.String()) + } + } +} + +// TestLiveGemma runs the full demo against a real Ollama with gemma3:4b and +// embeddinggemma. It is skipped unless JOLTRIN_OLLAMA_LIVE=1. +// +// JOLTRIN_OLLAMA_LIVE=1 go test ./examples/gemma_ollama -run TestLiveGemma -v +func TestLiveGemma(t *testing.T) { + if os.Getenv("JOLTRIN_OLLAMA_LIVE") != "1" { + t.Skip("set JOLTRIN_OLLAMA_LIVE=1 to run against a local Ollama") + } + baseURL := os.Getenv("OLLAMA_HOST_URL") + if baseURL == "" { + baseURL = "http://localhost:11434" + } + var out strings.Builder + if err := demo(context.Background(), baseURL, "gemma3:4b", "embeddinggemma", &out); err != nil { + t.Fatalf("demo: %v\n%s", err, out.String()) + } + t.Log("\n" + out.String()) + if !strings.Contains(out.String(), "recall f3") { + t.Errorf("rollback fact should be retrieved:\n%s", out.String()) + } +}