Files
oikos/cmd/nomos/eval/main.go
dtoro dd3076a23a
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(agent): close all post-fix remainders + golden eval harness (F.1-F.2, C.1-C.2, B.4-B.6, E.1-E.2)
Ships the 9 remaining post-fix items and a golden-conversation eval harness
that validates them against the live agent. All 4 evals pass.

SOUL.md (F.1, C.2, E.1):
- Consolidated three overlapping task-flow sections (MANDATORY TASK FLOW,
  'Every chat is a task', 'AFTER EVERY TASK: WRITE BACK') into one. ~50
  lines shorter. The operator's 'be more crisp' feedback.
- Added anti-patterns: don't re-execute on UI/sidebar complaints (C.2);
  don't re-run fleet-wide audits when same-day knowledge exists (E.1).
- Updated approval vocabulary in step 4 to match tasks.go (approved/yes/
  go/proceed/continue/ok/go ahead).

Tool-result strings (F.2):
- set_goal: tightened to 'Goal set. NEXT: pre-plan (read-only tools only).
  Then propose_plan. Do not call run.'
- update_plan_step: added '(Advance with update_plan_step + run; do not
  re-propose.)'

C.1 — completeTask rejects re-completion of a terminal session:
- Returns errTaskAlreadyComplete when status is already done/failed.
- The tool result directs: 'Task is already complete. Do not call
  complete_task again. If the operator pointed out a UI/sidebar
  inconsistency, fix it with update_plan_step...'

B.4 — Surface real model error text:
- chatWith's error event now includes finish_reason + refusal text:
  'Nomos returned an empty or unusable response (finish_reason=length).
  Retry or rephrase.' instead of generic 'empty response'.
- The resume-failed note already carried errText (B.3), which now has
  the real context.

B.5 — Back off between resume retries (4s, 8s):
- resumeSession now sleeps before attempts 1 and 2 (exponential backoff).
  A transient provider issue gets time to clear instead of 3 identical
  calls in 3 seconds.

B.6 — Don't persist the empty placeholder as a visible bubble:
- If a chat turn ends with no text and no tool calls (model empty-response'd
  and all retries failed), delete the placeholder row instead of persisting
  an empty bubble. The error was already streamed via done+error=true.

E.2 — list_lxcs last-audited hint:
- The list_lxcs result now includes last_audited_at — the most recent
  knowledge entry (tagged audit/update, or titled audit/update) linked
  via an 'about' edge. The agent can see 'nextcloud — last audited today'
  and skip re-running it.

Tool-call doubling bug fix (found by the eval harness):
- main.go + continue.go: the tool_use and tool_result events were both
  appending separate entries to the persisted tool_calls array, doubling
  every tool call in the transcript. Confirmed pre-existing (d9cdcee1,
  v0.3.x era). Fixed: tool_use creates the entry, tool_result merges the
  result into the same entry (matched by id). One entry per tool call.

Golden eval harness (cmd/nomos/eval/):
- A standalone Go program that loads YAML manifests of golden conversations
  + assertions, sends prompts to the chat endpoint, drains the SSE stream
  (keeping the agent's context alive), and scores structural assertions
  against the persisted transcript.
- 4 golden conversations covering: trivial read-only (degenerate case),
  plan + proceed (the original duplication bug), UI complaint (no re-exec),
  fleet audit (knowledge preferred over re-execution).
- Structural assertions only (tool-call sequences, plan steps, writeback,
  completion) — text quality is model-dependent and not scored.
- Run: go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest
  cmd/nomos/eval/evals/*.yaml  (~$0.10/run in OpenRouter credits).

Eval results (4/4 passed):
  trivial_readonly:              2 tool calls, no plan, no run
  plan_advances_on_proceed:     13 tool calls, propose_plan x1, writes back
  ui_complaint_no_rerun:        12 tool calls, propose_plan x1, writes back
  knowledge_preferred_over_rerun: 7 tool calls, search_knowledge x1, 0 run

Version 0.5.2 -> 0.5.3 (minor: eval harness + structural hardening).
2026-07-14 21:27:57 +02:00

336 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.010.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.
if c.Followup != "" {
if _, err := sendChat(ctx, gateway, sid, c.Followup); 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"`
}
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
}
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).
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
}
// 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
}