From 2a5a74f7c08bca91c72cf1040334cc3ef30b002b Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Mon, 4 May 2026 15:52:09 +0200 Subject: [PATCH] feat(routing): decision logger via brain MCP session_log Co-Authored-By: Claude Sonnet 4.6 --- internal/routing/log.go | 79 +++++++++++++++++++++++++++++++++++ internal/routing/log_test.go | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 internal/routing/log.go create mode 100644 internal/routing/log_test.go diff --git a/internal/routing/log.go b/internal/routing/log.go new file mode 100644 index 0000000..39305de --- /dev/null +++ b/internal/routing/log.go @@ -0,0 +1,79 @@ +package routing + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// LogEntry describes a single routing decision to log via the brain MCP. +type LogEntry struct { + SessionID string + Skill string // the original skill the call routed (e.g., "code_review") + Decision string // "local" or "claude" or "claude_fallback" + Message string // free-form, e.g. "model=qwen35, pass_rate=0.94" + ProjectRoot string + DurationMs int64 + Failed bool // true → final_status: "fail"; false → "skip" +} + +// Logger posts session_log entries to a brain MCP at BrainURL + /mcp. +type Logger struct { + BrainURL string + HTTP *http.Client +} + +// NewLogger creates a Logger with a 2-second HTTP timeout. +func NewLogger(brainURL string) *Logger { + return &Logger{ + BrainURL: brainURL, + HTTP: &http.Client{Timeout: 2 * time.Second}, + } +} + +// LogDecision posts a session_log MCP call. Errors are returned but the caller +// MUST NOT block real work on them — logging is best-effort. +func (l *Logger) LogDecision(ctx context.Context, e LogEntry) error { + status := "skip" + if e.Failed { + status = "fail" + } + payload := map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "name": "session_log", + "arguments": map[string]any{ + "session_id": e.SessionID, + "skill": "_routing", + "phase": "decide", + "final_status": status, + "message": fmt.Sprintf("%s: %s — %s", e.Skill, e.Decision, e.Message), + "duration_ms": e.DurationMs, + "project_root": e.ProjectRoot, + }, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("log: marshal: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.BrainURL+"/mcp", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("log: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := l.HTTP.Do(req) + if err != nil { + return fmt.Errorf("log: request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("log: server returned status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/routing/log_test.go b/internal/routing/log_test.go new file mode 100644 index 0000000..c59323a --- /dev/null +++ b/internal/routing/log_test.go @@ -0,0 +1,81 @@ +package routing_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mathiasbq/supervisor/internal/routing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoggerLogDecision(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/mcp", r.URL.Path) + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &captured)) + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{"content": []map[string]any{{"type": "text", "text": "ok"}}}}) + })) + defer srv.Close() + + l := routing.NewLogger(srv.URL) + err := l.LogDecision(context.Background(), routing.LogEntry{ + SessionID: "sess-1", + Skill: "code_review", + Decision: "local", + Message: "model=qwen35, pass_rate=0.94", + ProjectRoot: "/home/x/proj", + DurationMs: 1234, + Failed: false, + }) + require.NoError(t, err) + + params := captured["params"].(map[string]any) + assert.Equal(t, "tools/call", captured["method"]) + assert.Equal(t, "session_log", params["name"]) + + args := params["arguments"].(map[string]any) + assert.Equal(t, "_routing", args["skill"]) + assert.Equal(t, "decide", args["phase"]) + assert.Equal(t, "skip", args["final_status"]) + assert.Contains(t, args["message"].(string), "code_review: local") + assert.Equal(t, "sess-1", args["session_id"]) + assert.Equal(t, "/home/x/proj", args["project_root"]) + assert.Equal(t, float64(1234), args["duration_ms"]) +} + +func TestLoggerLogFailure(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}}) + })) + defer srv.Close() + + l := routing.NewLogger(srv.URL) + err := l.LogDecision(context.Background(), routing.LogEntry{ + SessionID: "s", Skill: "debug", Decision: "local", Message: "litellm down", Failed: true, + }) + require.NoError(t, err) + + args := captured["params"].(map[string]any)["arguments"].(map[string]any) + assert.Equal(t, "fail", args["final_status"]) +} + +func TestLoggerSurfacesUpstreamError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "down", http.StatusBadGateway) + })) + defer srv.Close() + + l := routing.NewLogger(srv.URL) + err := l.LogDecision(context.Background(), routing.LogEntry{Skill: "x", SessionID: "y", Decision: "local"}) + require.Error(t, err) +}