Files
homelab/review-bot/mistral.go
T
claude-botandClaude Opus 5.5 16d957dff1
review-bot queued
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
feat(review-bot): stream Mistral, add an Anthropic backend and a TypeSafe model selector
Mistral cut every POST /conversations off with HTTP 502 exactly ten minutes
after it started (about 30 times in the last 7 days), and every retry restarted
the whole review, so a review needing more than ten minutes could never finish.

- Mistral: the conversation now streams (stream=true, SSE). A stream that
  breaks off after the conversation started is looked up in the stored history
  (store=true, GET /conversations/{id}/history) before it is asked again. A
  broken stream is retryable like 429/502/503/504; a
  conversation.response.error event is not. One agent per model, named
  <AGENT_NAME>-<model>.
- Anthropic: new backend on the Messages API (Go SDK, streaming, adaptive
  thinking, effort high). Anthropic connects to the Gitea MCP server
  (gitea-mcp.renner.uno/mcp) with a read-only token; the toolset is in
  allowlist mode with only the read tools enabled. Paused turns are resumed,
  refusals use the server-side fallback on Opus/Sonnet 5.5.
- Models: MODELS lists backend:model = description, the first is the default.
  A backend whose keys are missing is switched off with a warning instead of
  stopping the bot.
- Selector: SELECTOR=typesafe asks TypeSafe (Jev, Choice) which model reviews
  each PR, from the title, description, size and changed files. A failure or
  an unusable choice falls back to the default model.
- Secrets: the Anthropic and TypeSafe keys reuse the wiki keys
  (apps/ai/wiki-anthropic, apps/ai/wiki-typesafe), like the Mistral key does.
  The MCP token is a second review-bot PAT scoped read:repository,read:issue,
  read:user, minted by a new rotate-mint-mcp step into
  infrastructure/review-bot#mcp_token (seeded with a placeholder). Each
  optional key has its own ExternalSecret and optional env ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:08:22 +02:00

374 lines
10 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// Mistral runs the review as a Mistral agent (one per model) with the read-only Gitea connector.
// The conversation is streamed: a request that waits silently for the whole review is cut off
// by Mistral's edge after ten minutes.
type Mistral struct {
base string
key string
agentPrefix string
connectorID string
http *http.Client
// recoverTries and recoverWait bound how often the history of a broken-off conversation is
// read to find its answer.
recoverTries int
recoverWait time.Duration
mu sync.RWMutex
agents map[string]string // model -> agent id
}
type Agent struct {
ID string `json:"id"`
Name string `json:"name"`
Model string `json:"model"`
Description string `json:"description"`
}
func (m *Mistral) request(ctx context.Context, method, path string, in any) (*http.Request, error) {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return nil, err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, m.base+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+m.key)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
return req, nil
}
func (m *Mistral) do(ctx context.Context, method, path string, in, out any) error {
req, err := m.request(ctx, method, path, in)
if err != nil {
return err
}
resp, err := m.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return newAPIError("mistral", method, path, resp, data)
}
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}
func promptHash(prompt string) string {
sum := sha256.Sum256([]byte(prompt))
return hex.EncodeToString(sum[:])[:12]
}
func (m *Mistral) agentName(model string) string { return m.agentPrefix + "-" + model }
// Prepare makes one agent per model match this binary.
func (m *Mistral) Prepare(ctx context.Context, models []string) error {
for _, model := range models {
a, err := m.EnsureAgent(ctx, m.agentName(model), model, m.connectorID, reviewPrompt)
if err != nil {
return fmt.Errorf("agent for %s: %w", model, err)
}
m.mu.Lock()
if m.agents == nil {
m.agents = map[string]string{}
}
m.agents[model] = a.ID
m.mu.Unlock()
}
return nil
}
func (m *Mistral) agent(model string) string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.agents[model]
}
// EnsureAgent makes the listed agent match this binary: it creates the agent when it does not
// exist and updates it when its model or the hash of its prompt differs, so git stays the source
// of truth for the prompt.
func (m *Mistral) EnsureAgent(ctx context.Context, name, model, connectorID, prompt string) (Agent, error) {
desc := "PR review, read-only Gitea connector. prompt sha256:" + promptHash(prompt)
spec := map[string]any{
"name": name,
"model": model,
"description": desc,
"instructions": prompt,
"tools": []map[string]string{{"type": "connector", "connector_id": connectorID}},
"completion_args": map[string]any{
"temperature": 0.1,
"max_tokens": 64000,
},
}
var agents []Agent
if err := m.do(ctx, http.MethodGet, "/agents?page_size=100", nil, &agents); err != nil {
return Agent{}, err
}
for _, a := range agents {
if a.Name != name {
continue
}
if a.Model == model && a.Description == desc {
return a, nil
}
var updated Agent
if err := m.do(ctx, http.MethodPatch, "/agents/"+a.ID, spec, &updated); err != nil {
return Agent{}, err
}
updated.ID = a.ID
return updated, nil
}
var created Agent
if err := m.do(ctx, http.MethodPost, "/agents", spec, &created); err != nil {
return Agent{}, err
}
return created, nil
}
// A tool is read-only when its name contains _get_, _list_ or _search_, or ends in _read.
var readOnlyTool = regexp.MustCompile(`_(get|list|search)_|_read$`)
// answerBuilder collects the answer from conversation outputs: the text of message outputs
// (thinking is dropped), and the tool calls, listing every one that is not read-only.
type answerBuilder struct {
a Answer
texts []string
lastID string
}
func (b *answerBuilder) tool(name string) {
b.a.ToolCalls++
if !readOnlyTool.MatchString(name) {
b.a.Violations = append(b.a.Violations, name)
}
}
// text adds content (a string, a chunk or a list of chunks) of message id. Content of the same
// message is joined directly, different messages are joined by a newline.
func (b *answerBuilder) text(id string, content json.RawMessage) {
var parts []string
var s string
if json.Unmarshal(content, &s) == nil {
parts = append(parts, s)
} else {
type chunk struct {
Type string `json:"type"`
Text string `json:"text"`
}
var chunks []chunk
if json.Unmarshal(content, &chunks) != nil {
var one chunk
if json.Unmarshal(content, &one) == nil {
chunks = []chunk{one}
}
}
for _, ch := range chunks {
if ch.Type == "text" {
parts = append(parts, ch.Text)
}
}
}
if len(parts) == 0 {
return
}
joined := strings.Join(parts, "")
if id != "" && id == b.lastID && len(b.texts) > 0 {
b.texts[len(b.texts)-1] += joined
return
}
b.texts = append(b.texts, joined)
b.lastID = id
}
func (b *answerBuilder) answer() Answer {
b.a.Text = strings.Join(b.texts, "\n")
return b.a
}
type entry struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Content json.RawMessage `json:"content"`
}
// parseAnswer reads the answer from a conversation response (`outputs`) or history (`entries`).
func parseAnswer(raw []byte) (Answer, error) {
var c struct {
Outputs []entry `json:"outputs"`
Entries []entry `json:"entries"`
}
if err := json.Unmarshal(raw, &c); err != nil {
return Answer{}, err
}
var b answerBuilder
for _, o := range append(c.Outputs, c.Entries...) {
switch o.Type {
case "tool.execution":
b.tool(o.Name)
case "message.output":
// Every entry is its own message.
b.lastID = ""
b.text("", o.Content)
}
}
return b.answer(), nil
}
type event struct {
Type string `json:"type"`
ConversationID string `json:"conversation_id"`
ID string `json:"id"`
Name string `json:"name"`
Content json.RawMessage `json:"content"`
Message string `json:"message"`
Code int `json:"code"`
}
// Ask streams one conversation with the model's agent. When the stream breaks off after the
// conversation started, the answer is looked up in the stored history before giving up.
func (m *Mistral) Ask(ctx context.Context, model, task string) (Answer, error) {
agentID := m.agent(model)
if agentID == "" {
return Answer{}, fmt.Errorf("no Mistral agent for %s yet", model)
}
in := map[string]any{"agent_id": agentID, "inputs": task, "store": true, "stream": true}
req, err := m.request(ctx, http.MethodPost, "/conversations", in)
if err != nil {
return Answer{}, err
}
req.Header.Set("Accept", "text/event-stream")
resp, err := m.http.Do(req)
if err != nil {
return Answer{}, &streamError{Provider: "mistral", Err: err}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return Answer{}, newAPIError("mistral", http.MethodPost, "/conversations", resp, data)
}
conv, ans, err := readStream(resp.Body)
if err == nil {
return ans, nil
}
var broken *streamError
if conv == "" || !errors.As(err, &broken) || ctx.Err() != nil {
return Answer{}, err
}
if rec, ok := m.recover(ctx, conv); ok {
return rec, nil
}
return Answer{}, err
}
// readStream reads the server-sent events of a conversation until conversation.response.done.
// It returns the conversation id as soon as it is known, also with an error.
func readStream(body io.Reader) (conv string, _ Answer, _ error) {
r := bufio.NewReaderSize(body, 64<<10)
var b answerBuilder
var data []string
dispatch := func() (done bool, err error) {
if len(data) == 0 {
return false, nil
}
raw := strings.Join(data, "\n")
data = data[:0]
var ev event
if err := json.Unmarshal([]byte(raw), &ev); err != nil {
return false, &streamError{Provider: "mistral", Err: fmt.Errorf("unreadable event: %w", err)}
}
switch ev.Type {
case "conversation.response.started":
conv = ev.ConversationID
case "message.output.delta":
b.text(ev.ID, ev.Content)
case "tool.execution.started":
b.tool(ev.Name)
case "conversation.response.error":
return false, fmt.Errorf("mistral conversation error %d: %s", ev.Code, ev.Message)
case "conversation.response.done":
return true, nil
}
return false, nil
}
for {
line, err := r.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
switch {
case line == "":
if done, derr := dispatch(); derr != nil {
return conv, Answer{}, derr
} else if done {
return conv, b.answer(), nil
}
case strings.HasPrefix(line, "data:"):
data = append(data, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " "))
}
if err != nil {
if done, derr := dispatch(); derr != nil {
return conv, Answer{}, derr
} else if done {
return conv, b.answer(), nil
}
if err == io.EOF {
err = io.ErrUnexpectedEOF
}
return conv, Answer{}, &streamError{Provider: "mistral", Err: err}
}
}
}
// recover reads the stored history of a conversation whose stream broke off, a few times, and
// takes its answer once it ends in a verdict.
func (m *Mistral) recover(ctx context.Context, conv string) (Answer, bool) {
for try := 0; try < m.recoverTries; try++ {
select {
case <-ctx.Done():
return Answer{}, false
case <-time.After(m.recoverWait):
}
var raw json.RawMessage
if err := m.do(ctx, http.MethodGet, "/conversations/"+conv+"/history", nil, &raw); err != nil {
continue
}
if a, err := parseAnswer(raw); err == nil {
if _, verdict := splitVerdict(a.Text); verdict != "" {
return a, true
}
}
}
return Answer{}, false
}