Files
gbr-games/cmd/gbrgames/diagnose.go
T
gregandClaude Opus 5.5 f11c5eaf2f feat(builder): bounded model requests, a safety check, and a clearer PR summary
- The drafter no longer grows one conversation. Each request is the rules
  and facts, the model's latest template, what is wrong now and one line per
  earlier round, so long builds can't push the facts out of the model's
  32K context (Ollama drops the oldest part silently). The image's scripts
  in the facts are capped at 24000 characters in all, and diagnosis check
  results at 16000.
- Every model call logs its prompt and answer tokens, with a warning near
  the context limit.
- unsafeCommands: a draft whose startup command or installConfig pipes a
  download into a shell, decodes base64, uses eval, raw network tools or
  /dev/tcp, sudo/su, setuid bits, writes /etc, touches cron/systemd/firewall,
  or looks like a miner goes back to the model. AUTHORING.md has the rule.
- The PR summary lists every address nodes would download from under
  "Review carefully", marks the measured checks as measured, and labels the
  research notes as the model's reading, not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:42:34 -04:00

390 lines
13 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// When a draft fails its boot, the builder investigates before anything is changed, the way a person
// would: it collects evidence (the server's errors, what others found about that error on the web), has
// the model state the cause and the evidence for it, lets the model ask for more checks (a search, a
// page, the files the server wrote, a boot with one thing changed), and only then asks for exactly that
// fix. Every attempt is logged, so a fix that changed nothing rules its cause out instead of being tried
// again, and the build stops when a ruled-out cause comes back.
type attempt struct {
Cause string `json:"cause"`
Fix string `json:"fix"`
Outcome string `json:"outcome"`
}
type checkRequest struct {
Type string `json:"type"` // search, read, files, boot_with
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Config map[string]string `json:"config,omitempty"`
Startup string `json:"startup,omitempty"`
}
type diagnosis struct {
Cause string `json:"cause"`
Evidence string `json:"evidence"`
Confidence string `json:"confidence"`
Fix string `json:"fix"`
Checks []checkRequest `json:"checks"`
}
var diagnosisSchema = map[string]any{
"type": "object",
"required": []string{"cause", "evidence", "confidence", "fix", "checks"},
"properties": map[string]any{
"cause": map[string]any{"type": "string"},
"evidence": map[string]any{"type": "string"},
"confidence": map[string]any{"enum": []string{"high", "medium", "low"}},
"fix": map[string]any{"type": "string"},
"checks": map[string]any{
"type": "array",
"maxItems": 3,
"items": map[string]any{
"type": "object",
"required": []string{"type"},
"properties": map[string]any{
"type": map[string]any{"enum": []string{"search", "read", "files", "boot_with"}},
"query": map[string]any{"type": "string"},
"url": map[string]any{"type": "string"},
"config": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
"startup": map[string]any{"type": "string"},
},
},
},
},
}
const diagnosePrompt = "You debug why a game server template fails on GBR Hosting nodes. Work like an engineer: find the " +
"cause from the evidence, never by guessing or by changing things to see what happens. The house rules below " +
"describe how servers really run; the boot test starts the server with exactly the settings a new server gets " +
"(settings left empty stay empty; only x-generate fields are filled).\n\n" +
"Answer with JSON: cause (what is wrong, specifically), evidence (which lines, files or results show it), " +
"confidence, fix (the exact change to the template), and checks. If the evidence doesn't settle the cause, ask " +
"for up to 3 checks instead of guessing:\n" +
"- {\"type\":\"search\",\"query\":\"...\"}: a web search, e.g. the exact error message with the game's name\n" +
"- {\"type\":\"read\",\"url\":\"...\"}: read a page\n" +
"- {\"type\":\"files\"}: boot the template once more and list the files and config files the server writes, " +
"where it writes them, and its error lines\n" +
"- {\"type\":\"boot_with\",\"config\":{\"Key\":\"value\"},\"startup\":\"...\"}: boot test with those settings " +
"and/or that startup command changed, to see whether that change makes the difference\n" +
"Leave checks empty once you are sure. Never propose a cause the attempt log has ruled out.\n\n"
type investigator struct {
m model
gameName, gamesDir, id, agentImage string
searx, reader, rules string
log []attempt
}
func newInvestigator(m model, gameName, gamesDir, id, agentImage string) *investigator {
rules, _ := os.ReadFile("AUTHORING.md")
return &investigator{m: m, gameName: gameName, gamesDir: gamesDir, id: id, agentImage: agentImage,
searx: os.Getenv("SEARXNG_URL"), reader: os.Getenv("READER_URL"), rules: string(rules)}
}
// diagnose investigates one failure and returns the model's diagnosis (checks answered).
func (iv *investigator) diagnose(failure string) (*diagnosis, error) {
template, _ := os.ReadFile(filepath.Join(iv.gamesDir, iv.id, "game.yaml"))
var b strings.Builder
fmt.Fprintf(&b, "Game: %s\n\n## The template\n```yaml\n%s```\n\n## What failed\n%s\n", iv.gameName, template, failure)
if ev := iv.webEvidence(failure); ev != "" {
b.WriteString("\n## What the web says about its errors\n" + ev)
}
b.WriteString("\n## Attempt log\n" + iv.logText())
messages := []chatMessage{
{Role: "system", Content: diagnosePrompt + iv.rules},
{Role: "user", Content: b.String()},
}
var d diagnosis
for round := 0; ; round++ {
fmt.Printf("[diagnose] round %d: asking %s what is wrong\n", round+1, iv.m.name())
answer, err := iv.m.chat(messages, diagnosisSchema)
if err != nil {
return nil, err
}
d = diagnosis{}
if err := json.Unmarshal([]byte(trimToJSON(answer)), &d); err != nil {
return nil, fmt.Errorf("unreadable diagnosis: %w", err)
}
fmt.Printf("[diagnose] cause (%s confidence): %s\n", d.Confidence, firstLine(d.Cause))
if len(d.Checks) == 0 || round == 2 {
return &d, nil
}
messages = append(messages, chatMessage{Role: "assistant", Content: answer})
messages = append(messages, chatMessage{Role: "user", Content: "Results of your checks:\n\n" + iv.runChecks(d.Checks) +
"\nNow give your diagnosis. Ask for more checks only if these still don't settle the cause."})
}
}
// webEvidence searches for the server's most telling error line and reads the best answer.
func (iv *investigator) webEvidence(failure string) string {
errs := distinctErrors(failure, 3)
if len(errs) == 0 || iv.searx == "" {
return ""
}
return iv.search(iv.gameName + " dedicated server " + errs[0])
}
func (iv *investigator) search(query string) string {
if iv.searx == "" {
return "(no search configured)\n"
}
fmt.Printf("[diagnose] searching: %s\n", query)
hits, err := searxSearch(iv.searx, query)
if err != nil || len(hits) == 0 {
return fmt.Sprintf("search %q: no results\n", query)
}
var b strings.Builder
fmt.Fprintf(&b, "Search %q:\n", query)
for i, h := range hits {
if i == 5 {
break
}
fmt.Fprintf(&b, "- %s — %s: %.300s\n", h.URL, h.Title, h.Content)
}
if iv.reader != "" {
if text, err := readPageLimit(iv.reader, hits[0].URL, 6000); err == nil {
fmt.Fprintf(&b, "\nTop result (%s):\n%s\n", hits[0].URL, text)
}
}
return b.String()
}
func (iv *investigator) runChecks(checks []checkRequest) string {
var b strings.Builder
boots := 0
for _, c := range checks {
switch c.Type {
case "search":
b.WriteString(iv.search(c.Query))
case "read":
fmt.Printf("[diagnose] reading %s\n", c.URL)
if text, err := readPageLimit(iv.reader, c.URL, 8000); err != nil {
fmt.Fprintf(&b, "read %s: %v\n", c.URL, err)
} else {
fmt.Fprintf(&b, "Page %s:\n%s\n", c.URL, text)
}
case "files", "boot_with":
// Boots take minutes: at most two per round
if boots++; boots > 2 {
fmt.Fprintf(&b, "%s: skipped (at most two boots per round)\n", c.Type)
continue
}
if c.Type == "files" {
b.WriteString(iv.filesCheck())
} else {
b.WriteString(iv.bootWith(c))
}
}
b.WriteString("\n")
}
// Bounded, like every request: pages can be long, and the model's context is not
if s := b.String(); len(s) > 16000 {
return s[:16000] + "\n...(cut: check results too long)\n"
}
return b.String()
}
// filesCheck boots the template and reports what the server wrote and where.
func (iv *investigator) filesCheck() string {
fmt.Println("[diagnose] booting once more to see what the server writes")
r, err := firstBootGame(iv.gamesDir, iv.id)
if err != nil {
return "files: the boot could not run: " + err.Error() + "\n"
}
var b strings.Builder
fmt.Fprintf(&b, "Files check: %s\n", r.Outcome)
for i, f := range r.FilesCreated {
if i == 60 {
fmt.Fprintf(&b, " ... and %d more files\n", len(r.FilesCreated)-60)
break
}
fmt.Fprintf(&b, " file %s (%d bytes)\n", f.Path, f.Size)
}
for i, s := range r.Settings {
if i == 80 {
break
}
fmt.Fprintf(&b, " setting %s | %s | %s = %s\n", s.File, s.Section, s.Key, s.Value)
}
if dirs := outsideDirs(r); len(dirs) > 0 {
fmt.Fprintf(&b, " written outside /server and /data (lost on every restart): %s\n", strings.Join(dirs, ", "))
}
for i, l := range r.ErrorLines {
if i == 10 {
break
}
fmt.Fprintf(&b, " error line: %s\n", l.Text)
}
return b.String()
}
// bootWith boot-tests a copy of the template with settings and/or the startup command changed.
func (iv *investigator) bootWith(c checkRequest) string {
desc := describeChange(c)
fmt.Printf("[diagnose] boot test with %s\n", desc)
games, err := os.MkdirTemp("", "gbr-diagnose-")
if err != nil {
return "boot_with: " + err.Error() + "\n"
}
defer os.RemoveAll(games)
if err := copyTemplateWith(filepath.Join(iv.gamesDir, iv.id), filepath.Join(games, iv.id), c); err != nil {
return "boot_with: " + err.Error() + "\n"
}
output, passed, problems, err := agentBoot(games, iv.id, iv.agentImage, false)
switch {
case err != nil:
return "boot_with: the boot could not run: " + err.Error() + "\n"
case problems != nil:
return "boot_with: " + strings.Join(problems, "; ") + "\n"
case passed:
return fmt.Sprintf("Boot with %s: PASSED (%s)\n", desc, bootVerdict(output))
}
return fmt.Sprintf("Boot with %s: %s\n", desc, strings.TrimPrefix(bootFailure(output), "booting your template with the node-agent (the real deploy code) failed: "))
}
func describeChange(c checkRequest) string {
var parts []string
for _, k := range sortedKeysString(c.Config) {
parts = append(parts, fmt.Sprintf("%s=%q", k, c.Config[k]))
}
if c.Startup != "" {
parts = append(parts, fmt.Sprintf("startup %q", c.Startup))
}
if len(parts) == 0 {
return "no change"
}
return strings.Join(parts, ", ")
}
// copyTemplateWith copies games/<id>/ to dst with the requested settings and startup command applied.
func copyTemplateWith(src, dst string, c checkRequest) error {
g, err := loadMap(filepath.Join(src, "game.yaml"))
if err != nil {
return err
}
if len(c.Config) > 0 {
defaults, _ := g["defaultConfig"].(map[string]any)
if defaults == nil {
defaults = map[string]any{}
}
for k, v := range c.Config {
defaults[k] = v
}
g["defaultConfig"] = defaults
}
if c.Startup != "" {
g["defaultStartupCommand"] = c.Startup
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
if err := writeGame(filepath.Join(dst, "game.yaml"), g); err != nil {
return err
}
if raw, err := os.ReadFile(filepath.Join(src, "test.yaml")); err == nil {
return os.WriteFile(filepath.Join(dst, "test.yaml"), raw, 0o644)
}
return nil
}
var (
digits = regexp.MustCompile(`\d+`)
// Timestamps the shared linePrefix doesn't cover, like 10/26/2026 01:12:02:
slashDate = regexp.MustCompile(`^\s*\d{1,2}/\d{1,2}/\d{2,4}[ T][0-9:.,]+:?\s*`)
)
// distinctErrors picks the server's error-looking lines, without timestamps and with repeats removed.
// The boot test's own verdict lines are not the server's errors.
func distinctErrors(output string, n int) []string {
seen := map[string]bool{}
var out []string
for _, raw := range strings.Split(output, "\n") {
raw = strings.TrimSpace(ansi.ReplaceAllString(raw, ""))
if strings.HasPrefix(raw, "[boot-test]") || strings.HasPrefix(raw, "booting your template") {
continue
}
line := strings.TrimSpace(slashDate.ReplaceAllString(linePrefix.ReplaceAllString(raw, ""), ""))
if line == "" || !errorWords.MatchString(line) {
continue
}
key := digits.ReplaceAllString(strings.ToLower(line), "N")
if seen[key] {
continue
}
seen[key] = true
if len(line) > 160 {
line = line[:160]
}
out = append(out, line)
if len(out) == n {
break
}
}
return out
}
func (iv *investigator) logText() string {
if len(iv.log) == 0 {
return "(first attempt)\n"
}
var b strings.Builder
for i, a := range iv.log {
fmt.Fprintf(&b, "%d. cause: %s\n fix: %s\n outcome: %s\n", i+1, a.Cause, a.Fix, a.Outcome)
}
b.WriteString("A fix whose outcome is unchanged ruled its cause out: look for another cause.\n")
return b.String()
}
// ruledOut reports whether the diagnosis names a cause an earlier fix already disproved.
func (iv *investigator) ruledOut(d *diagnosis) bool {
key := causeKey(d.Cause)
for _, a := range iv.log {
if causeKey(a.Cause) == key && !strings.HasPrefix(a.Outcome, "fixed") {
return true
}
}
return false
}
var nonWord = regexp.MustCompile(`[^a-z0-9]+`)
func causeKey(s string) string {
return strings.Trim(nonWord.ReplaceAllString(strings.ToLower(s), " "), " ")
}
func (iv *investigator) record(d *diagnosis) {
iv.log = append(iv.log, attempt{Cause: d.Cause, Fix: d.Fix, Outcome: "(not booted yet)"})
}
// outcome fills in how the last fix went: fixed, the same failure, or a new one.
func (iv *investigator) outcome(passed bool, before, after string) {
if len(iv.log) == 0 {
return
}
a := &iv.log[len(iv.log)-1]
switch {
case passed:
a.Outcome = "fixed: the boot test passed"
case causeKey(firstLine(before)) == causeKey(firstLine(after)):
a.Outcome = "unchanged: " + firstLine(after)
default:
a.Outcome = "a different failure: " + firstLine(after)
}
}
func fixRequest(failure string, d *diagnosis) []string {
return []string{fmt.Sprintf("%s\n\nDiagnosis:\n- cause: %s\n- evidence: %s\n- fix: %s\n\nApply exactly this fix and change "+
"nothing unrelated to it.", firstLine(failure), d.Cause, d.Evidence, d.Fix)}
}