# 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:** `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`) ```go 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. ```go 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 ```go type Provider interface { Stream(ctx context.Context, req Request) (iter.Seq2[Event, error], error) Caps() Caps Name() string } ``` ### 4.4 Capabilities ```go 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`) ```go 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/.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`) ```go 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` → `/.nub/config.toml` → Umgebungsvariablen (`NUB_*`) → Flags. ```toml [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 ```go 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: ```markdown --- 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 `__` 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 — 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.** 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`: ```markdown # 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.