857 lines
31 KiB
Markdown
857 lines
31 KiB
Markdown
# 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/<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`)
|
||
|
||
```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` →
|
||
`<repo>/.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 `<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 — 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.
|