feat(routing): decision logger via brain MCP session_log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
79
internal/routing/log.go
Normal file
79
internal/routing/log.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
81
internal/routing/log_test.go
Normal file
81
internal/routing/log_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user