// Command nomos-eval runs golden conversation evals against a live nomos // gateway. It loads a YAML manifest of conversations + assertions, sends // each prompt to the chat endpoint, waits for the turn(s) to finish, and // scores assertions against the persisted transcript. // // Usage: // // go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml // // The gateway must already be running (nomos serve, or the docker container). // Each conversation costs real OpenRouter credits (~$0.01–0.05 each). // // Manifest format — see evals/example.yaml. Assertions are scored against the // final transcript: tool calls made, plan steps, final session status, and // whether the turn completed. The runner does NOT judge text quality — only // structural properties that can be checked deterministically from the // persisted state. This is deliberate: text quality is model-dependent and // noisy; structure is what the Go gates + SOUL.md should enforce. package main import ( "bytes" "context" "encoding/json" "flag" "fmt" "io" "net/http" "os" "path/filepath" "time" ) func main() { gateway := flag.String("gateway", "http://localhost:8092", "nomos gateway URL") manifestGlob := flag.String("manifest", "evals/*.yaml", "glob of manifest files to run") timeout := flag.Duration("timeout", 2*time.Minute, "per-conversation timeout") flag.Parse() if err := health(*gateway); err != nil { fmt.Fprintf(os.Stderr, "gateway not reachable at %s: %v\n", *gateway, err) os.Exit(1) } files, err := filepath.Glob(*manifestGlob) if err != nil { fmt.Fprintf(os.Stderr, "glob %s: %v\n", *manifestGlob, err) os.Exit(1) } if len(files) == 0 { fmt.Fprintf(os.Stderr, "no manifests matched %s\n", *manifestGlob) os.Exit(1) } total, passed, failed := 0, 0, 0 for _, f := range files { convs, err := loadManifest(f) if err != nil { fmt.Fprintf(os.Stderr, "load %s: %v\n", f, err) os.Exit(1) } for _, c := range convs { total++ name := c.Name if name == "" { name = fmt.Sprintf("conversation-%d", total) } fmt.Printf("=== %s (from %s) ===\n", name, filepath.Base(f)) res := runConversation(context.Background(), *gateway, c, *timeout) if res.Passed { passed++ fmt.Printf(" ✅ PASS (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount) } else { failed++ fmt.Printf(" ❌ FAIL (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount) } for _, a := range res.Assertions { mark := "✅" if !a.Passed { mark = "❌" } fmt.Printf(" %s %s: %s\n", mark, a.Name, a.Detail) } } } fmt.Printf("\n=== Summary: %d/%d passed, %d failed ===\n", passed, total, failed) if failed > 0 { os.Exit(1) } } func health(gateway string) error { resp, err := http.Get(gateway + "/healthz") if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("healthz status %d", resp.StatusCode) } return nil } // runConversation sends the prompt (and any followup), waits for each turn to // finish, then scores assertions against the final transcript. func runConversation(ctx context.Context, gateway string, c conversation, timeout time.Duration) convResult { start := time.Now() deadline := time.Now().Add(timeout) res := convResult{} // Send the initial prompt (no session_id → creates a new session). sid, err := sendChat(ctx, gateway, "", c.Prompt) if err != nil { res.Assertions = []assertionResult{{Name: "send_prompt", Passed: false, Detail: err.Error()}} res.Duration = time.Since(start) return res } res.SessionID = sid // Wait for the first turn to finish. if err := waitForTurn(ctx, gateway, sid, deadline); err != nil { res.Assertions = []assertionResult{{Name: "turn_complete", Passed: false, Detail: err.Error()}} res.Duration = time.Since(start) return res } // Send followup if any. for _, fu := range c.followups() { if _, err := sendChat(ctx, gateway, sid, fu); err != nil { res.Assertions = []assertionResult{{Name: "send_followup", Passed: false, Detail: err.Error()}} res.Duration = time.Since(start) return res } if err := waitForTurn(ctx, gateway, sid, deadline); err != nil { res.Assertions = []assertionResult{{Name: "followup_turn_complete", Passed: false, Detail: err.Error()}} res.Duration = time.Since(start) return res } } // Fetch the final transcript + session state. transcript, session, err := fetchTranscript(ctx, gateway, sid) if err != nil { res.Assertions = []assertionResult{{Name: "fetch_transcript", Passed: false, Detail: err.Error()}} res.Duration = time.Since(start) return res } res.ToolCallCount = transcript.toolCallCount() res.Duration = time.Since(start) // Score assertions. res.Assertions = scoreAssertions(c.Assertions, transcript, session) res.Passed = true for _, a := range res.Assertions { if !a.Passed { res.Passed = false break } } return res } // sendChat POSTs to /chat and extracts the session_id from the first SSE // event, then KEEPS READING the stream until it ends (the `done` event or // the connection closes). This is critical: the chat handler uses // r.Context() which cancels when the HTTP connection closes — if we stop // reading after the session event, the agent's work gets canceled mid-turn. // We must drain the full stream so the agent completes its turn server-side. func sendChat(ctx context.Context, gateway, sid, message string) (string, error) { body, _ := json.Marshal(map[string]string{"session_id": sid, "message": message}) req, _ := http.NewRequestWithContext(ctx, "POST", gateway+"/chat", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 && resp.StatusCode != 202 { b, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("chat status %d: %s", resp.StatusCode, string(b)) } // For a reconnect (sid != ""), the body is 202 with no stream. if sid != "" { io.Copy(io.Discard, resp.Body) return sid, nil } // Read the SSE stream, capturing the session_id from the first session // event, and draining the rest so the agent's turn completes. The stream // ends when the server closes it (after the `done` event) or when the // request context cancels. dec := newSSEReader(resp.Body) sessionID := "" for { ev, err := dec.next() if err != nil { if sessionID == "" { return "", fmt.Errorf("no session event before stream end: %w", err) } return sessionID, nil } if ev["type"] == "session" && sessionID == "" { if s, ok := ev["session_id"].(string); ok { sessionID = s } } // Keep reading until the stream ends — don't return early. } } // waitForTurn polls the session until its last_active_at stops advancing for // 8 seconds (the turn ended) or the session reaches a terminal status. We // can't rely on status=done alone because a trivial task may auto-complete // while a plan-proposing task stays in 'executing' waiting for approval. func waitForTurn(ctx context.Context, gateway, sid string, deadline time.Time) error { var lastActive string stableSince := time.Now() for { if time.Now().After(deadline) { return fmt.Errorf("timeout waiting for turn to complete") } _, session, err := fetchTranscript(ctx, gateway, sid) if err != nil { time.Sleep(2 * time.Second) continue } if session.LastActive != lastActive { lastActive = session.LastActive stableSince = time.Now() } if time.Since(stableSince) >= 8*time.Second { return nil // turn is idle — consider it complete } if session.Status == "done" || session.Status == "failed" { return nil } time.Sleep(2 * time.Second) } } type transcript struct { Messages []struct { Role string `json:"role"` Content struct { Text string `json:"text"` ToolCalls []map[string]any `json:"tool_calls"` } `json:"content"` } `json:"messages"` // PlanSteps is fetched from /sessions/{id}/plan (P5 plan_generations // assertion). Each step carries a `generation` int; distinctGenerations // counts the unique values. nil when the endpoint returned no plan // (e.g. a pure-DB Q&A with no propose_plan call). PlanSteps []planStep `json:"steps"` } // planStep is one step from /sessions/{id}/plan, carrying only the fields the // eval needs: the generation number (P2 iteration counter). type planStep struct { Generation int `json:"generation"` Status string `json:"status"` Title string `json:"title"` } func (t transcript) toolCallCount() int { n := 0 for _, m := range t.Messages { n += len(m.Content.ToolCalls) } return n } func (t transcript) toolNames() []string { var names []string for _, m := range t.Messages { for _, tc := range m.Content.ToolCalls { if name, ok := tc["name"].(string); ok { names = append(names, name) } } } return names } // distinctGenerations counts unique plan generation values across all plan // steps. Used by the `plan_generations` assertion (P2 iteration). Returns 0 // when there are no plan steps (no propose_plan was called). func (t transcript) distinctGenerations() int { seen := map[int]bool{} for _, s := range t.PlanSteps { seen[s.Generation] = true } return len(seen) } type sessionState struct { ID string `json:"id"` Status string `json:"status"` Outcome string `json:"outcome"` LastActive string `json:"last_active_at"` } // fetchTranscript fetches the messages from /sessions/{id} (which returns // only session_id + messages) and the session metadata from /sessions // (which returns status/outcome/last_active_at for each session). P5 also // fetches /sessions/{id}/plan for the plan_generations assertion. func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sessionState, error) { var t transcript resp, err := http.Get(gateway + "/sessions/" + sid) if err != nil { return t, sessionState{}, err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return t, sessionState{}, err } if err := json.Unmarshal(b, &t); err != nil { return t, sessionState{}, err } // Fetch the plan (steps with generation numbers) for the // plan_generations assertion. A 404 or empty response is fine — a // pure-DB Q&A with no propose_plan has no plan. ?all=true returns every // generation so the assertion can count them (the default view returns // only the current generation). if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan?all=true"); perr == nil { if planResp.StatusCode == 200 { pb, _ := io.ReadAll(planResp.Body) _ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field } planResp.Body.Close() } // The detail endpoint doesn't return status/outcome — fetch from the // sessions list and find the matching id. s, err := fetchSessionMeta(ctx, gateway, sid) return t, s, err } // fetchSessionMeta fetches /sessions and extracts the one matching sid. func fetchSessionMeta(ctx context.Context, gateway, sid string) (sessionState, error) { resp, err := http.Get(gateway + "/sessions") if err != nil { return sessionState{}, err } defer resp.Body.Close() var list struct { Sessions []sessionState `json:"sessions"` } if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { return sessionState{}, err } for _, s := range list.Sessions { if s.ID == sid { return s, nil } } return sessionState{}, fmt.Errorf("session %s not found in list", sid) } // convResult is the outcome of one conversation. type convResult struct { SessionID string Passed bool Duration time.Duration ToolCallCount int Assertions []assertionResult } type assertionResult struct { Name string Passed bool Detail string }