nub/docs/KONZEPT.md
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

31 KiB
Raw Permalink Blame History

Coding-Agent CLI in Go — Konzept & Umsetzungsplan

Projektname: nub — der Kern einer Sache, „the nub of it".

Dieses Dokument ist als Arbeitsgrundlage für die Umsetzung mit einem Coding-Agenten gedacht. Abschnitt 10 beschreibt, wie es dafür eingesetzt wird.


1. Ziel & Abgrenzung

Ziel

Ein schlanker, terminal-basierter Coding-Agent als einzelnes statisches Go-Binary, ohne Runtime-Abhängigkeit beim Anwender.

Im Umfang:

  • Agent-Loop mit Tool-Calling und Streaming
  • Provider: OpenAI-kompatibel (Start), Architektur offen für Anthropic/Gemini
  • Eingebaute Tools: read, write, edit, bash, glob, grep, todo
  • MCP-Client (stdio + Streamable HTTP)
  • Skills mit Progressive Disclosure
  • Kontext-Dateien: AGENTS.md, REPOMAP.md, frei konfigurierbar
  • Session-Persistenz als Baum inkl. Rewind/Branch
  • Auto-Compaction
  • Modi: interaktiv (TUI) und Print/JSON (-p)

Nicht im Umfang (v1):

  • Desktop-/Web-UI
  • Sub-Agents
  • LSP-Integration
  • Plugin-System für Fremdcode (MCP + Skills decken den Bedarf ab)
  • Eigenes Model-Hosting/Routing

Leitprinzipien

  1. Der Kern kennt kein Terminal. Jede Ausgabe läuft über einen Event-Channel.
  2. Das interne Datenmodell ist reicher als jeder Provider. Adapter mappen herunter, nie herauf.
  3. Kontext ist ein Budget. Jede Komponente, die den Prompt befüllt, deklariert ihre Kosten und respektiert Obergrenzen.
  4. Kein Zustand außerhalb des Session-Baums. Alles, was einen Turn beeinflusst hat, ist rekonstruierbar.

2. Architektur

2.1 Paketstruktur

cmd/nub/                 Einstiegspunkt, Flag-Parsing, Modus-Auswahl
internal/config/         Layered Config, Auflösung, Validierung
internal/llm/            Message, Block, Event, Provider-Interface, Caps
internal/llm/openai/     OpenAI-kompatibler Adapter
internal/llm/registry/   Modell-Metadaten, Endpoint-Auflösung
internal/tool/           Tool-Interface, Registry, Permissions
internal/tool/builtin/   read, write, edit, bash, glob, grep, todo, read_skill
internal/mcpc/           MCP-Client, Bridge MCP-Tool -> tool.Tool
internal/skill/          Skill-Discovery, Frontmatter-Parsing, Index
internal/ctxasm/         Context Assembler (System-Prompt-Aufbau)
internal/session/        Baum-Modell, Store, Compaction
internal/agent/          Loop, Turn, Steering, Cancellation
internal/tokens/         Schätzung + Kalibrierung gegen echte Usage
internal/ui/plain/       Renderer für Print-Modus
internal/ui/tui/         Bubbletea-TUI (Meilenstein 6)

Abhängigkeitsrichtung: uiagent → {llm, tool, session, ctxasm} → config. Kein Paket unterhalb von agent importiert ui. Diese Regel ist die wichtigste Strukturvorgabe des Projekts; sie wird per Test abgesichert (Abschnitt 7).

2.2 Datenfluss eines Turns

User-Input
  └─> agent.Loop
        ├─> ctxasm.Assemble()       → System-Blöcke + Tool-Definitionen
        ├─> session.PathToHead()    → Message-Historie
        ├─> llm.Provider.Stream()   → iter.Seq2[Event, error]
        │     └─> UIEvent-Channel (Text-Deltas, Tool-Start, ...)
        ├─> tool.Registry.Run()     → parallel, ctx-gebunden
        │     └─> UIEvent-Channel (Tool-Output, Diffs)
        └─> session.Append()        → neue Nodes, Head verschieben
        (Schleife bis StopReason != tool_use)

2.3 Rahmenbedingungen

Go 1.24. Untere Grenze ist 1.23 wegen iter.Seq2 im Provider-Interface (4.3). In der go.mod fixieren und in der AGENTS.md erwähnen — sonst werden Channel-basierte Iteratoren generiert.

Dependency-Allowlist. Ein schlankes Tool mit 180 Modulen in der go.sum ist ein Widerspruch in sich. Erlaubt sind:

Zweck Modul
TUI charmbracelet/bubbletea, lipgloss, bubbles, glamour, chroma
MCP modelcontextprotocol/go-sdk
Config ein TOML-Parser (BurntSushi/toml)
Globbing bmatcuk/doublestar
IDs oklog/ulid

Alles andere: stdlib. Insbesondere kein Viper und kein Cobra — flag aus der stdlib deckt das Kommandoset ab. Neue Abhängigkeiten sind eine bewusste Entscheidung, keine Nebenwirkung.

Logging. log/slog als JSON in eine Datei unter ~/.local/state/nub/log (bzw. $XDG_STATE_HOME), Level über NUB_LOG. Niemals auf stdout — das zerstört den JSON-Modus und die TUI. log.Printf und fmt.Println sind unterhalb von internal/ui verboten; derselbe Architektur-Test wie für E-04 prüft das mit.


3. Entscheidungen

Format: Entscheidung → Begründung → Konsequenz.

E-01 — Internes Message-Modell ist blockbasiert

Nicht OpenAIs flaches Schema (tool_calls am Assistant-Objekt, role:"tool" als eigene Message), sondern Content-Blöcke.

Begründung: Anthropic und Gemini brauchen Blöcke. Blöcke → flach ist mechanisch, flach → Blöcke ist verlustbehaftet. Thinking-Blöcke mit Signaturen haben im flachen Modell keinen Platz.

Konsequenz: Der OpenAI-Adapter ist am Anfang aufwendiger als nötig. Akzeptiert.

E-02 — Provider-SDK-Typen erreichen den Kern nicht

openai-go (oder direkt net/http) nur innerhalb von internal/llm/openai.

Begründung: Sonst zementiert man OpenAIs Modell im ganzen Projekt.

Konsequenz: ~400 Zeilen eigenes SSE-Parsing und Mapping. Empfehlung: direkt net/http + bufio.Scanner, das SDK bringt bei diesem Zuschnitt wenig.

E-03 — Session ist ein Baum, ab Tag 1

Nicht als Liste mit später aufgesetztem Branching.

Begründung: Rewind/Branch kostet initial fast nichts und ist nachträglich kaum einzuziehen. Compaction wird dadurch ebenfalls sauber modellierbar (E-08).

Konsequenz: Jeder Zugriff auf „die Historie" geht über PathToHead(), nie über einen Slice.

E-04 — Alle Kernausgabe läuft über einen Event-Channel

agent.Run gibt <-chan UIEvent zurück. Kein fmt.Println unterhalb von internal/ui.

Begründung: Print-Modus, JSON-Modus und TUI fallen dadurch als drei dünne Consumer ab. Das ist die Voraussetzung dafür, die TUI zuletzt bauen zu können.

Konsequenz: Disziplin nötig. Wird per Test abgesichert (9.4).

E-05 — Tool-Ergebnisse sind zweigeteilt

Result{ForModel string, ForUI any}.

Begründung: Das Modell braucht bei edit ein knappes „ok, 3 Zeilen geändert", die UI einen gerenderten Diff. Ein gemeinsamer String zwingt zu einem schlechten Kompromiss und verbrennt Tokens.

Konsequenz: Jedes Tool definiert einen UI-Typ. Der Print-Renderer fällt bei unbekannten Typen auf ForModel zurück.

E-06 — MCP-Tools werden beim Start geladen, Liste dann eingefroren

Alle konfigurierten Server werden parallel beim Start verbunden.

Begründung: Tool-Definitionen sind Teil des gecachten Prompt-Prefix. Eine mitten in der Session wachsende Tool-Liste zerstört den Cache in jeder Runde.

Konsequenz: Startup-Latenz. Gegenmaßnahme: Verbindungsaufbau läuft parallel, während der Nutzer den ersten Prompt tippt. Connect-Timeout (Default 5s) → Server fällt raus, Warnung in die Statuszeile, Loop startet trotzdem.

E-07 — MCP-Allowlist pro Server ist Pflicht

Ohne tools = [...] werden alle Tools geladen, aber es wird gewarnt, sobald ein Server mehr als 15 Tools beisteuert.

Begründung: Ein GitHub-MCP-Server kostet 2040k Tokens permanent.

Konsequenz: Zusätzlich Server-Profile pro Projekt (profiles.review = ["github"]), damit die Last nur dort anfällt, wo sie gebraucht wird.

E-08 — Compaction ist ein Knoten im Baum

Ein Summary-Node mit Replaces []NodeID. PathToHead() läuft von Head Richtung Wurzel und stoppt am ersten Summary-Node.

Begründung: Kein destruktives Löschen, das Original bleibt für Rewind erhalten, und die Compaction selbst ist branchbar.

Konsequenz: Der Pfad-Walk braucht diese Sonderbehandlung. Trivial, aber muss getestet sein.

E-09 — Kein echter Tokenizer in v1

Schätzung über len(text)/4 (bzw. /3 für CJK), kalibriert gegen die tatsächliche usage aus jeder API-Antwort.

Begründung: Tokenizer sind pro Modell verschieden und die exakte Zahl ist nur für den Compaction-Trigger relevant. Ein 15%-Sicherheitsabstand ist billiger als die Abhängigkeit.

Konsequenz: Compaction-Schwelle konservativ (Default: 75% des Fensters).

E-10 — Capabilities pro Endpoint, nicht pro Protokoll

„OpenAI-kompatibel" ist ein Sammelbegriff. Groq, Cerebras, vLLM, Ollama, LM Studio und OpenRouter verhalten sich unterschiedlich.

Konsequenz: Caps-Struct (4.4), befüllt aus Modell-Registry + Config-Overrides.

E-11 — Permissions: statisch konfiguriert, keine interaktiven Popups in v1

Modus pro Tool (ask/auto/deny) plus Pfad-Regeln. ask ist im Print-Modus ein harter Fehler.

Begründung: Interaktive Rückfragen erzwingen eine UI-Abhängigkeit im Kern (verletzt E-04) und sind im Print-Modus sinnlos.

Konsequenz: v1 setzt darauf, dass sicherheitskritische Nutzung im Container läuft. Interaktive Bestätigung kann in Meilenstein 6 als UI-Event nachgerüstet werden.

E-12 — Retry gehört in den Provider-Layer, nicht in den Loop

Wiederholungsversuche passieren unterhalb von Provider.Stream. Der Agent-Loop sieht entweder einen erfolgreichen Stream oder einen endgültigen Fehler.

Begründung: Der Loop kennt keine HTTP-Semantik, und Retry-Logik an zwei Stellen führt zu multiplikativen Wartezeiten.

Konsequenz: Ein Retry ist nur zulässig, solange noch keine Blöcke an den Consumer emittiert wurden. Reißt der Stream mitten in einer Antwort ab, wird der Turn als Fehler beendet — ein halb ausgegebener Text darf nicht doppelt erscheinen. Details in 5.9.


4. Datenmodelle

4.1 Messages & Blöcke (internal/llm)

type Role string
const (
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
)

type BlockKind string
const (
	KindText       BlockKind = "text"
	KindThinking   BlockKind = "thinking"
	KindToolUse    BlockKind = "tool_use"
	KindToolResult BlockKind = "tool_result"
	KindImage      BlockKind = "image"
)

type Block struct {
	Kind BlockKind

	// text / thinking
	Text string

	// tool_use
	ID    string
	Name  string
	Input json.RawMessage

	// tool_result
	ToolUseID string
	Result    []Block
	IsError   bool

	// image
	MediaType string
	Data      []byte

	// Opaker Provider-Ballast (Anthropic-Signatures, reasoning-IDs).
	// Muss unverändert zurückgesendet werden können.
	Raw json.RawMessage

	// Cache-Breakpoint. Adapter ohne Cache-Steuerung ignorieren das Feld.
	CacheMark bool
}

type Message struct {
	Role    Role
	Content []Block
}

type Request struct {
	Model     string
	System    []Block   // eigenes Feld, NICHT als Message in der Historie
	Messages  []Message
	Tools     []ToolDef
	MaxTokens int
	Temp      *float64
}

type ToolDef struct {
	Name        string
	Description string
	Schema      json.RawMessage // kanonisch: JSON Schema
}

Wichtig: System ist ein Request-Feld, keine Message. Sonst landet es im Session-Baum und wird bei jedem Rewind mitgeschleppt.

4.2 Stream-Events (internal/llm)

Modelliert nach dem expliziteren Anthropic-Schema; der OpenAI-Adapter synthetisiert.

type Event interface{ isEvent() }

type BlockStart struct { Index int; Block Block } // Block ohne Inhalt, nur Kind/ID/Name
type BlockDelta struct { Index int; Text string; PartialJSON string }
type BlockStop  struct { Index int }
type Done       struct { Stop StopReason; Usage Usage }

type StopReason string
const (
	StopEnd       StopReason = "end_turn"
	StopToolUse   StopReason = "tool_use"
	StopMaxTokens StopReason = "max_tokens"
)

type Usage struct{ InputTokens, OutputTokens, CacheReadTokens, CacheWriteTokens int }

4.3 Provider-Interface

type Provider interface {
	Stream(ctx context.Context, req Request) (iter.Seq2[Event, error], error)
	Caps() Caps
	Name() string
}

4.4 Capabilities

type Caps struct {
	ParallelToolCalls bool
	UsageInStream     bool
	SystemRole        string // "system" | "developer" | "" = eigenes Request-Feld
	ExplicitCache     bool
	Reasoning         bool
	StrictSchemas     bool
	MaxContext        int
	SupportsImages    bool
}

4.5 Session-Baum (internal/session)

type NodeKind string
const (
	NodeMessage NodeKind = "message"
	NodeSummary NodeKind = "summary"
)

type Node struct {
	ID       string    // ULID
	ParentID string    // "" = Wurzel
	Kind     NodeKind
	Message  llm.Message
	Replaces []string  // nur bei NodeSummary
	Meta     NodeMeta
}

type NodeMeta struct {
	Model     string
	Usage     llm.Usage
	CreatedAt time.Time
	Label     string
	Bookmark  bool
}

type Session struct {
	ID    string
	Nodes map[string]*Node
	Head  string
}

func (s *Session) PathToHead() []llm.Message  // Wurzel→Head, stoppt am Summary
func (s *Session) Branch(from string) error   // setzt Head auf einen früheren Knoten
func (s *Session) Append(n *Node) error

Persistenz: Append-only JSONL unter .nub/sessions/<id>.jsonl, eine Zeile pro Node. Der Head wird als eigener Record-Typ ({"head":"..."}) angehängt; beim Laden gewinnt der letzte. Crash-sicher, branchbar, ohne DB-Abhängigkeit. SQLite bleibt eine spätere Option, falls Suche über Sessions gebraucht wird.

Jeder Record beginnt mit "v": 1. Ein Feld, kostet jetzt nichts, und das Format wird sich ändern. Beim Laden: unbekannte höhere Version → Fehler mit klarer Meldung, nicht stillschweigend halb parsen. Dasselbe gilt für die Config (version = 1).

Dateirechte und Git. Sessiondateien enthalten Tool-Ausgaben und damit potenziell Umgebungsvariablen, Tokens und Dateiinhalte. Deshalb:

  • .nub/ mit 0700, Sessiondateien mit 0600
  • beim ersten Start .nub/ nach .git/info/exclude schreiben, nicht in die .gitignore — die wird committet und gehört dem Repo, nicht dem Tool

4.6 Tool-Interface (internal/tool)

type Tool interface {
	Name() string
	Description() string
	Schema() json.RawMessage
	Run(ctx context.Context, input json.RawMessage, env Env) (Result, error)
}

type Result struct {
	ForModel string // was in den tool_result-Block geht
	ForUI    any    // strukturiert; z.B. DiffResult, FileResult
	IsError  bool
}

type Env struct {
	Cwd       string
	RepoRoot  string
	Emit      func(UIEvent) // für streamende Tools (bash)
}

4.7 Config (internal/config)

Schichtung, spätere gewinnt: Defaults → ~/.config/nub/config.toml<repo>/.nub/config.toml → Umgebungsvariablen (NUB_*) → Flags.

[model]
default   = "gpt-4o"
endpoint  = "openai"      # Verweis auf [[endpoint]]

[[endpoint]]
name     = "openai"
base_url = "https://api.openai.com/v1"
api_key  = "env:OPENAI_API_KEY"

[[endpoint]]
name     = "local"
base_url = "http://localhost:11434/v1"
api_key  = "none"
  [endpoint.caps]           # Overrides gegen die Registry
  parallel_tool_calls = false
  usage_in_stream     = false

[context]
files      = ["AGENTS.md", "REPOMAP.md"]
walk_up    = true
max_tokens = 20000

[skills]
paths = ["~/.nub/skills", ".nub/skills"]

[[mcp]]
name    = "github"
command = "gh-mcp-server"
args    = []
tools   = ["create_issue", "get_pull_request"]

[[mcp]]
name = "linear"
url  = "https://mcp.linear.app/mcp"

[profiles]
review  = ["github"]
default = []

[permissions]
read = "auto"
glob = "auto"
grep = "auto"
write = "auto"
edit  = "auto"
bash  = "auto"
deny_paths  = [".git/**", "**/.env", "**/id_rsa*"]
deny_bash   = ["rm -rf /", "git push --force*"]

5. Komponenten im Detail

5.1 OpenAI-Adapter

Aufgaben: Request-Mapping, SSE-Parsing, Tool-Call-Akkumulation, Event-Synthese.

Kritische Punkte:

  1. Tool-Call-Akkumulation. Argumente kommen als String-Fragmente über choices[0].delta.tool_calls[i]. id und name kommen typischerweise nur im ersten Chunk; index ist die einzige verlässliche Korrelation. Zustand in map[int]*partialCall halten. Tolerant sein: manche Server senden den kompletten Call in einem Chunk, manche zählen index falsch, manche liefern name nachträglich.
  2. tools weglassen statt leer senden. "tools": [] führt bei mehreren Implementierungen zu Fehlern.
  3. Usage. Nur mit stream_options: {include_usage: true} und nur bei Endpoints mit UsageInStream. Sonst über die Schätzung aus internal/tokens.
  4. System-Rolle. Je nach Caps.SystemRole als system, developer oder gar nicht (dann als erste User-Message mit Trennmarker).
  5. reasoning_content. Nicht-standardisiertes Feld (DeepSeek u.a.). Wenn vorhanden und Caps.Reasoning, als KindThinking-Block mappen.
  6. [DONE]-Sentinel ist kein JSON — vor dem Unmarshal abfangen.
  7. Fehler mitten im Stream. Manche Endpoints senden ein error-Objekt als SSE-Event statt eines HTTP-Fehlers. Behandeln.

5.2 Agent-Loop

func (l *Loop) Run(ctx context.Context, in <-chan Input) <-chan UIEvent

Ablauf pro Turn:

  1. Kontext zusammenbauen, Historie holen, Provider streamen.
  2. Deltas als UIEvents emittieren, parallel Blöcke akkumulieren.
  3. Bei StopToolUse: alle Tool-Calls parallel starten (Errgroup, abgeleiteter ctx).
  4. Ergebnisse einsammeln, als tool_result-Blöcke in eine User-Message packen.
  5. Zurück zu 1, bis StopEnd.

Steering: Der in-Channel wird auch während eines laufenden Turns gelesen. Eine Steering-Nachricht wird nach Abschluss des aktuell laufenden Tools eingeschleust; noch nicht gestartete Tools werden abgebrochen und bekommen einen tool_result mit IsError und Hinweistext (das Protokoll verlangt für jeden tool_use ein Ergebnis — das Auslassen ist ein häufiger Bug). Follow-up-Nachrichten werden gequeued.

Cancellation: Zwei Ebenen. Abbruch des Turns (Stream + Tools beenden, Session konsistent lassen) und Programmende. Ein abgebrochener Turn muss einen gültigen Baumzustand hinterlassen — d.h. entweder alle tool_use-Blöcke bekommen ein Ergebnis, oder der unvollständige Assistant-Node wird nicht committet.

Safety-Limits: max_turns pro Anfrage (Default 50), max_tool_time pro Tool (Default 120s), Wiederholungserkennung (identischer Tool-Call 3× hintereinander → Abbruch mit Hinweis).

5.3 Eingebaute Tools

Tool Anmerkungen
read Zeilennummern voranstellen, Offset/Limit-Parameter, Default-Limit ~2000 Zeilen, Binärerkennung
write Vollständiger Dateiinhalt, Parent-Dirs anlegen, ForUI = Diff gegen alt
edit Exakter String-Replace. Bei mehreren Treffern Fehler mit Trefferzahl, nicht raten. replace_all-Flag
bash Timeout, Prozessgruppen-Kill (Setpgid, syscall.Kill(-pgid)), Output-Truncation bei ~30k Zeichen (Mitte kürzen, Anfang+Ende behalten), streamt über Env.Emit
glob Doublestar-Pattern, .gitignore respektieren, nach mtime sortiert
grep Regex, in-process (regexp + Walker) oder rg falls vorhanden. Kontext-Zeilen-Parameter
todo In-Memory-Liste im Session-State. Messbarer Nutzen bei langen Tasks
read_skill Lädt Skill-Body nach Name (5.5)

Pfad-Regel für alle Datei-Tools: nur unterhalb von RepoRoot, Symlinks auflösen und erneut prüfen, deny_paths anwenden.

5.4 Context Assembler (internal/ctxasm)

Baut Request.System in dieser Reihenfolge — von stabil nach volatil:

  1. Basis-System-Prompt (eingebettet via go:embed, überschreibbar durch SYSTEM.md)
  2. Umgebung: OS, Shell, Repo-Root, Branch — statisch für die Session, keine Zeitstempel, kein Git-Status
  3. Konfigurierte Kontext-Dateien (AGENTS.md, REPOMAP.md, …)
  4. Skills-Index (nur Name + Description)
  5. CacheMark = true auf dem letzten Block ←

Alles Dynamische (Git-Status, Datum, Tool-Ausgaben) gehört in Messages, nie in System.

Datei-Auflösung bei walk_up = true: Von cwd bis RepoRoot hochlaufen, alle Treffer sammeln, in der Reihenfolge Repo-Root → cwd einfügen (näher am Arbeitsverzeichnis gewinnt inhaltlich, weil später gelesen). Herkunft jeder Datei protokollieren und im /context-Kommando anzeigen.

Budget: context.max_tokens wird geprüft. Bei Überschreitung wird die letzte Datei gekürzt und eine Warnung emittiert — nicht stillschweigend abgeschnitten.

REPOMAP.md: Es gibt ein nub map-Kommando, das eine Repomap generiert (Verzeichnisbaum + exportierte Symbole; für Go über go/ast, sprachagnostisch als Fallback der Dateikopf). Erzeugt wird ein Header mit dem Git-Commit, gegen den die Map gebaut wurde. Beim Start wird gewarnt, wenn der aktuelle HEAD abweicht — eine falsche Repomap ist schlechter als keine.

5.5 Skills (internal/skill)

Struktur:

.nub/skills/
  refactoring/
    SKILL.md
    checklist.md

SKILL.md mit YAML-Frontmatter:

---
name: refactoring
description: Vorgehen für größere Refactorings in diesem Repo. Nutzen, wenn mehr als 3 Dateien betroffen sind.
---

# Refactoring

...Body...

Mechanik: Beim Start werden alle Skills gefunden, aber nur name + description in den Prompt geschrieben (~30 Tokens pro Skill). Das Tool read_skill(name) liefert den Body. Weitere Dateien im Skill-Verzeichnis findet das Modell selbst über read.

Das ist der Kostenunterschied zu MCP: ein Skill kostet 30 Tokens bis zur Nutzung, ein MCP-Tool 300800 permanent.

5.6 MCP-Client (internal/mcpc)

Basis: github.com/modelcontextprotocol/go-sdk (v1.5+, stabil, stdio + Streamable HTTP, Client-OAuth).

  • Verbindungsaufbau parallel beim Start, Timeout 5s pro Server (E-06)
  • Tool-Namen werden mit <server>__ prefixt
  • Allowlist aus der Config filtert vor der Registrierung
  • Bridge: MCP-Tool → tool.Tool. ForModel = textuelle Content-Blöcke, ForUI = strukturierter Content
  • Server-Ausfall zur Laufzeit: Tool-Aufruf liefert IsError, kein Panic, kein Loop-Abbruch
  • Reconnect wird in v1 nicht versucht (würde die Tool-Liste ändern, siehe E-06)

5.7 Compaction

Trigger: geschätzte Prompt-Tokens > compact_at (Default 75%) von Caps.MaxContext.

Ablauf:

  1. Ältere Hälfte des Pfads auswählen; die letzten N Turns (Default 4) bleiben immer roh.
  2. Zusammenfassung mit separatem Prompt erzeugen (gleiches Modell, ggf. konfigurierbar günstigeres).
  3. NodeSummary mit Replaces = IDs der zusammengefassten Knoten anhängen.
  4. Head verschieben.

Die Zusammenfassung muss enthalten: bearbeitete Dateien, getroffene Entscheidungen, offene Aufgaben, wiederherstellbarer Zustand. Nicht: Prosa über den Gesprächsverlauf.

5.8 UI

Print-Modus (-p): Textdeltas nach stdout, Tool-Aktivität nach stderr, Exit-Code ≠ 0 bei Fehler. --mode json gibt stattdessen einen NDJSON-Event-Stream aus — das ist die Integrationsschnittstelle.

TUI (Meilenstein 6): Bubbletea + Lipgloss + Bubbles, glamour für Markdown, chroma für Syntax-Highlighting. Komponenten: Message-Liste (virtualisiert), Editor mit Historie, Statuszeile (Modell, Tokens, Kosten), Modal-Stack für /model und /tree, Diff-Overlay.

Kommandos: /model, /tree, /branch, /compact, /context, /mcp, /skills, /export, /clear.

5.9 Fehlerbehandlung & Retry

Angesiedelt im Provider-Layer (E-12). Die Klassifikation ist wichtiger als der Backoff:

Situation Verhalten
429 mit Retry-After Header respektieren, bis 3 Versuche
429 ohne Header Exponentiell 1s/2s/4s + Jitter
500, 502, 503, 504 Exponentiell, bis 3 Versuche
Netzwerk-/Timeout-Fehler vor dem ersten Byte Retry
Stream reißt nach emittierten Blöcken ab Kein Retry — Turn als Fehler beenden
Context-Length überschritten Kein Retry — Compaction auslösen, dann einmalig neu
400 (ungültiges Schema o.ä.) Kein Retry, Fehler durchreichen
401/403 Kein Retry, Hinweis auf Konfiguration

Context-Length-Fehler zuverlässig zu erkennen ist unschön, weil jeder Endpoint sie anders formuliert. Pragmatisch: HTTP 400 plus Substring-Match auf context length, context_length_exceeded, too many tokens, maximum context. Bei Unsicherheit lieber als normalen Fehler behandeln, als in eine Compaction-Schleife zu laufen — deshalb nach einer ausgelösten Compaction genau ein weiterer Versuch, danach Abbruch.

Alle Retries werden über slog protokolliert (2.3) und als UIEvent gemeldet, damit im interaktiven Betrieb sichtbar ist, warum es hängt.

Exit-Codes im Print-Modus: 0 Erfolg, 1 Agent-/Provider-Fehler, 2 Konfigurationsfehler, 3 Abbruch durch Nutzer, 4 Limit erreicht (max_turns, Budget).


6. Ablaufplan

Jeder Meilenstein ist eigenständig lauffähig und testbar.

M1 — Kern-Loop (Grundlage)

Umfang: internal/llm (Typen + Interface), OpenAI-Adapter mit SSE und Tool-Call-Akkumulation, internal/tool mit read/write/edit/bash/glob/grep, minimaler Loop, Ausgabe über simplen stdout-Renderer, Konfiguration nur über Umgebungsvariablen.

Fertig, wenn: Ein mehrstufiger Task („lies X, ändere Y, führe die Tests aus") läuft gegen einen echten Endpoint durch. Ctrl+C bricht mitten in einem bash-Aufruf sauber ab und tötet die Prozessgruppe. Golden-File-Tests für den Adapter grün.

M2 — Session-Baum & Persistenz

Umfang: Baum-Modell, JSONL-Store, PathToHead, nub resume, nub sessions, /branch-Äquivalent als CLI-Flag.

Fertig, wenn: Eine Session lässt sich fortsetzen; ein Rewind auf einen früheren Knoten und Weiterarbeit erzeugen einen zweiten Ast, beide bleiben ladbar.

M3 — Config & Kontext

Umfang: Layered Config (TOML), Endpoint-/Caps-Auflösung, Context Assembler mit AGENTS.md/REPOMAP.md/frei konfigurierten Dateien, walk_up, Budget-Prüfung, nub map.

Fertig, wenn: /context zeigt jede geladene Datei mit Herkunft und Token-Kosten. Budget-Überschreitung erzeugt eine sichtbare Warnung.

M4 — Tokens & Compaction

Umfang: Schätzung + Kalibrierung, todo-Tool, Auto-Compaction als Summary-Node, /compact manuell.

Fertig, wenn: Eine künstlich verlängerte Session compactet automatisch, läuft weiter, und ein Rewind auf einen Knoten vor der Compaction funktioniert weiterhin.

M5 — Skills & MCP

Umfang: Skill-Discovery + Index + read_skill, MCP-Client mit Allowlist, Profile, /skills und /mcp.

Fertig, wenn: Ein Skill wird nachweislich erst bei Bedarf geladen (Token-Delta messbar). Ein absichtlich kaputter MCP-Server verhindert den Start nicht.

M6 — TUI

Umfang: Bubbletea-Oberfläche, Streaming-Rendering, Modals, Diff-Overlay, Steering (Enter) und Follow-up (Alt+Enter), Kommandos.

Fertig, wenn: Die TUI ist ein reiner Consumer des Event-Channels; das Entfernen von internal/ui/tui lässt den Rest kompilieren und -p weiterhin funktionieren.

M7 — Politur

Prompt-Cache gegen echte usage-Zahlen verifizieren (Cache-Read-Tokens müssen ab dem zweiten Turn deutlich steigen), Token-Anzeige in der Statuszeile, /export, README. Distribution bleibt go install (Abschnitt 9).

Reihenfolge-Begründung: Die TUI ist der teuerste und der einzige vollständig wegwerfbare Teil. Sie kommt zuletzt, damit die Kern-Schnittstellen vorher unter echter Nutzung validiert werden — M1M5 sind über -p bereits produktiv nutzbar.


7. Teststrategie

Golden Files für Provider. Echte SSE-Streams aufzeichnen (inklusive der kaputten von lokalen Servern) und als Fixtures unter internal/llm/openai/testdata/ ablegen. Der Adapter-Test spielt sie ab und vergleicht die erzeugte Event-Folge. Beim Hinzufügen eines zweiten Providers läuft dieselbe Suite gegen den neuen Adapter — dort zeigt sich sofort, wo das interne Modell leckt. Das ist der Mechanismus, der „später erweiterbar" tatsächlich einlöst, nicht das Interface allein.

Fake-Provider für den Loop. Ein Provider, der skriptierte Event-Folgen abspielt. Damit sind Multi-Turn-Verhalten, Steering, Cancellation und Fehlerpfade ohne Netzwerk testbar.

Tools. In t.TempDir() mit einem Fixture-Repo. Besonders: edit mit mehrdeutigem Treffer, bash mit Timeout und Kindprozessen, Pfad-Escape-Versuche (../, Symlinks).

Architektur-Test. Ein Test, der die Import-Graphen prüft: kein Paket unterhalb von internal/agent darf internal/ui importieren. Sichert E-04 mechanisch ab.


8. Bekannte Fallen

  • Fehlende tool_result-Blöcke. Jeder tool_use braucht ein Ergebnis, auch bei Abbruch. Sonst lehnt die API den nächsten Request ab.
  • Cache-Busting durch Dynamik im System-Prompt. Zeitstempel, Git-Status, Sessionlänge — nichts davon gehört nach oben.
  • Wachsende Tool-Liste. Siehe E-06.
  • Verwaiste Kindprozesse. bash ohne Prozessgruppen-Kill hinterlässt Zombies, die den Terminal-Zustand ruinieren.
  • Provider-SDK-Typen im Kern. Der schleichendste Fehler; einmal drin, überall drin.
  • Zu frühe TUI. Bindet Wochen und zementiert Annahmen über die Ausgabe.
  • Session als Slice. Nachträgliches Branching ist ein Rewrite.

9. Festlegungen

Die zuvor offenen Punkte sind für den aktuellen Zuschnitt (persönliches Werkzeug, kleiner Nutzerkreis) entschieden. Kein Punkt davon ist irreversibel, aber ein Agent soll sie nicht je Session neu beantworten.

Punkt Entscheidung
Session-Ablage Repo-lokal unter .nub/sessions. Portabel, kein globaler Index nötig
Auth Datei mit 0600 unter $XDG_CONFIG_HOME/nub. Kein Keyring
Kosten-Tracking Entfällt. Nur Token-Zahlen in der Statuszeile
Permissions Container-Haltung aus E-11. Keine interaktive Bestätigung, auch nicht in M6
Plattformen Linux und macOS. Windows nur über WSL, kein PowerShell-Pfad
Telemetrie Keine
Distribution go install. Kein Goreleaser, kein Self-Update

Bewusst zurückgestellt (nicht vergessen, nur nicht jetzt): Eval-Suite gegen ein Fixture-Repo, nub init, System-Prompt-Hash in NodeMeta, Lizenz- und Contribution-Fragen. Der erste Punkt wird relevant, sobald du merkst, dass du viel am System-Prompt oder an Tool-Beschreibungen änderst und nicht mehr beurteilen kannst, ob es besser wird.


10. Umsetzung mit einem Coding-Agenten

  1. Dieses Dokument als docs/KONZEPT.md ins Repo. Dazu eine kurze AGENTS.md:

    # nub
    Schlanker CLI-Coding-Agent in Go. Konzept: docs/KONZEPT.md
    
    - Go 1.24. `iter.Seq2` wird verwendet — keine Channel-Iteratoren.
    - Definition of Done: `go test ./...` und `go vet ./...` grün.
    - Kein Paket unterhalb von internal/agent importiert internal/ui (E-04).
    - Keine Ausgabe auf stdout außerhalb von internal/ui. Logging via slog in
      eine Datei (Abschnitt 2.3).
    - Neue Abhängigkeiten nur aus der Allowlist in Abschnitt 2.3. Kein Cobra,
      kein Viper.
    - Festlegungen aus Abschnitt 9 sind entschieden — nicht neu verhandeln.
    
  2. Ein Meilenstein pro Session. Nicht mehr. Am Anfang jeder Session: Abschnitt 3 (Entscheidungen), 4 (Datenmodelle) und den jeweiligen Meilenstein aus Abschnitt 6 in den Kontext geben.

  3. Abschnitt 4 wörtlich übernehmen lassen. Die Typen sind die Vertragsgrundlage zwischen den Meilensteinen; Abweichungen dort kosten später am meisten.

  4. Golden Files zuerst. In M1 vor dem Adapter die Fixtures aufzeichnen lassen (ein kleines Skript, das einen echten Stream mitschneidet). Ohne sie ist jede spätere Änderung am Adapter Blindflug.

  5. Nach jedem Meilenstein die Akzeptanzkriterien manuell prüfen, nicht nur die Tests. Besonders bei M1 (Abbruchverhalten) und M4 (Compaction + Rewind).

  6. Wenn eine Frage auftaucht, die Abschnitt 3 oder 9 nicht beantwortet, selbst entscheiden und dort ergänzen — nicht den Agenten implizit entscheiden lassen. Sonst stehen dieselbe Frage drei Mal unterschiedlich beantwortet im Code.