31 KiB
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
- Der Kern kennt kein Terminal. Jede Ausgabe läuft über einen Event-Channel.
- Das interne Datenmodell ist reicher als jeder Provider. Adapter mappen herunter, nie herauf.
- Kontext ist ein Budget. Jede Komponente, die den Prompt befüllt, deklariert ihre Kosten und respektiert Obergrenzen.
- 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: ui → agent → {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 20–40k 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/mit0700, Sessiondateien mit0600- beim ersten Start
.nub/nach.git/info/excludeschreiben, 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:
- Tool-Call-Akkumulation. Argumente kommen als String-Fragmente über
choices[0].delta.tool_calls[i].idundnamekommen typischerweise nur im ersten Chunk;indexist die einzige verlässliche Korrelation. Zustand inmap[int]*partialCallhalten. Tolerant sein: manche Server senden den kompletten Call in einem Chunk, manche zählenindexfalsch, manche liefernnamenachträglich. toolsweglassen statt leer senden."tools": []führt bei mehreren Implementierungen zu Fehlern.- Usage. Nur mit
stream_options: {include_usage: true}und nur bei Endpoints mitUsageInStream. Sonst über die Schätzung ausinternal/tokens. - System-Rolle. Je nach
Caps.SystemRolealssystem,developeroder gar nicht (dann als erste User-Message mit Trennmarker). reasoning_content. Nicht-standardisiertes Feld (DeepSeek u.a.). Wenn vorhanden undCaps.Reasoning, alsKindThinking-Block mappen.[DONE]-Sentinel ist kein JSON — vor dem Unmarshal abfangen.- 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:
- Kontext zusammenbauen, Historie holen, Provider streamen.
- Deltas als UIEvents emittieren, parallel Blöcke akkumulieren.
- Bei
StopToolUse: alle Tool-Calls parallel starten (Errgroup, abgeleiteter ctx). - Ergebnisse einsammeln, als
tool_result-Blöcke in eine User-Message packen. - 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:
- Basis-System-Prompt (eingebettet via
go:embed, überschreibbar durchSYSTEM.md) - Umgebung: OS, Shell, Repo-Root, Branch — statisch für die Session, keine Zeitstempel, kein Git-Status
- Konfigurierte Kontext-Dateien (
AGENTS.md,REPOMAP.md, …) - Skills-Index (nur Name + Description)
- →
CacheMark = trueauf 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 300–800 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:
- Ältere Hälfte des Pfads auswählen; die letzten N Turns (Default 4) bleiben immer roh.
- Zusammenfassung mit separatem Prompt erzeugen (gleiches Modell, ggf. konfigurierbar günstigeres).
NodeSummarymitReplaces= IDs der zusammengefassten Knoten anhängen.- 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 — M1–M5 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. Jedertool_usebraucht 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.
bashohne 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
-
Dieses Dokument als
docs/KONZEPT.mdins Repo. Dazu eine kurzeAGENTS.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. -
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.
-
Abschnitt 4 wörtlich übernehmen lassen. Die Typen sind die Vertragsgrundlage zwischen den Meilensteinen; Abweichungen dort kosten später am meisten.
-
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.
-
Nach jedem Meilenstein die Akzeptanzkriterien manuell prüfen, nicht nur die Tests. Besonders bei M1 (Abbruchverhalten) und M4 (Compaction + Rewind).
-
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.