W czasie eksperymentów z agentami kodującymi i lokalnymi modelami typu Qwen Coder zacząłem się zastanawiać, co tak naprawdę dzieje się między agentem a modelem na poziomie protokołu i kodu. Przy okazji chciałem odświeżyć Go — wbrew trendowi nakazującemu "odpuszczenie" kodu na korzyść pisania promptów — i tak powstał działający prototyp agenta.
Co w kablu piszczy, czyli rzut okiem na protokół
Cała komunikacja agenta z modelem to zwykły HTTP. Jako przykład weźmy Ollamę z modelem Qwen Coder.
Klasyczny HTTP POST na /v1/chat/completions
{
"model": "qwen3-coder",
"stream": true,
"messages": [
{"role": "system", "content": "You are a coding assistant..."},
{"role": "user", "content": "What does the main.go file do?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "read_file",
"description": "...",
"parameters": {...}
}
}
]
}
"Magia" z pojawiającymi się w miarę myślenia tokenami ukryta jest w polu stream: true. Serwer odpowiada Content-Type: text/event-stream i nie zamyka body — tokeny przychodzą partiami jako pary event: + data::
event: content_block_delta
data: {"delta": {"type": "text_delta", "text": "Hello"}}
event: content_block_delta
data: {"delta": {"type": "text_delta", "text": " world!"}}
event: message_stop
data: {}
Większość eventów to text_delta — fragmenty odpowiedzi. Ale co gdy model zamiast tekstu chce coś zrobić?
Tool call — model prosi, agent wykonuje
Nie ma tu żadnego specjalnego protokołu — model po prostu generuje JSON zamiast tekstu:
1. Agent → model POST z tools[]
2. Model → agent blok tool_call: { name: "read_file", arguments: {"path": "main.go"} }
3. Agent wykonuje narzędzie (czyta plik)
4. Agent → model nowy POST z tą samą historią + wiadomość role: "tool" z wynikiem
5. Model → agent odpowiedź tekstowa na podstawie zawartości pliku
{
"role": "tool",
"tool_call_id": "call_01abc",
"content": "package main\n\nfunc main() {..."
}
W każdym requeście wysyłamy pole tools[] z pełną listą dostępnych narzędzi i ich schematami (JSON Schema). Model nie ma dostępu do żadnego "rejestru" — widzi tylko to co mu wyślemy. Jeśli agent obsługuje MCP, narzędzia z serwerów MCP są doklejane do tej samej listy — model nie wie, że część z nich pochodzi z zewnątrz.
- Argument narzędzia może przyjść fragmentami przez SSE (
input_json_delta) — składamy go jako string, parsujemy dopiero pocontent_block_stop - Model może zażądać kilku narzędzi na raz (parallel tool calls) — agent wykonuje je wszystkie, odsyła wszystkie wyniki, dopiero potem model kontynuuje
- Dlaczego model "wie" kiedy wywołać narzędzie? Zależy od system promptu i treningu — nie od żadnego protokołu po stronie agenta
Implementacja w kodzie
Na poziomie kodu idea jest prosta — pętla while z klientem HTTP, który wysyła historię rozmowy, dostaje stream tokenów, używa narzędzi i od nowa.
user input → messages[] → Chat() → <-chan Event → terminal output
↑ |
└── tool results ────────┘
W miarę implementacji sprawa staje się bardziej złożona — kod powinien być czytelny, logicznie podzielony na pliki i moduły. I tak na przykład dostawcy LLM różnie wystawiają swoje modele, Anthropic ma /v1/messages i własny format SSE, OpenAI/Ollama używa /v1/chat/completions, szczegóły trzeba ukryć za wspólnym interfejsem. Z pomocą przychodzi Factory Pattern zwracający gotową instancję w zależności od wybranego dostawcy.
Istota logiki agenta
W kodzie odpowiedzialność za przebieg wątku rozłożona jest na trzy pliki, każdy z jasno określoną rolą:
-
cmd/root.go→runREPL()— composition root. Tu łączymy wszystko: parsowanie flag, ładowanie configu, tworzenie providera, budowanie rejestru narzędzi, podłączenie MCP serwerów. Nie ma tu logiki biznesowej, a jedynie łączenie zależności wstrzykiwanych do agenta. -
agent.go→Run()— pętla REPL. Czyta input od użytkownika, steruje wywołanymi komendami (/compact,/model,/provideritd.), wywołujeturn()na każde zapytanie, zarządza sesją (zapis, resume, tworzenie tytułu), auto-compact gdy context window > 80%. -
turn.go→turn()— jeden cykl agenta. BudujeChatRequest, wywołujeChat(), przekazuje stream doconsumeStream(), obsługuje agentic loop (tool_use → wykonaj → powtórz). Obsługa Ctrl+C (SIGINT → cancel context → przerwanie cyklu). -
stream.go→consumeStream()— konsumpcja kanału eventów z providera. Czyta<-chan Event, drukuje tokeny na terminal, akumuluje tool calls, filtruje bloki<think>, podświetla kod, pilnuje idle timeout.
Go i kanały — streaming bez blokowania
Odpowiedź HTTP od modelu może trwać kilkanaście sekund — jak nie zablokować reszty programu? Chat() zwraca <-chan Event natychmiast, a parsowanie SSE dzieje się w goroutine w tle. Kanał jest buforowany (64 elementy), więc provider nie blokuje na odbiorcy.
// Provider interface — jeden kontrakt dla wszystkich providerów
type Provider interface {
Chat(ctx context.Context, req ChatRequest) (<-chan Event, error)
}
// Wewnątrz Anthropic.Chat():
ch := make(chan Event, 64) // buforowany — nie blokujemy na agencie
go a.streamResponse(ctx, resp.Body, ch)
return ch, nil // zwracamy natychmiast, goroutine chodzi w tle
Kanał jest jednokierunkowy — provider pisze, agent czyta. Jeśli użytkownik naciśnie Ctrl+C, context.WithCancel anuluje context, zamyka HTTP body i goroutine kończy się naturalnie. Na wypadek gdyby model po prostu przestał odpowiadać, consumeStream pilnuje idle timeout przez time.NewTimer. Goroutines są tanie, kanały to wbudowany mechanizm synchronizacji — Go tutaj po prostu pasuje.
// Agent konsumuje stream
for event := range ch {
switch event.Type {
case provider.EventTextDelta:
fmt.Print(event.Text) // drukujemy token w czasie rzeczywistym
case provider.EventToolUseStart:
// model chce narzędzia — zapamiętujemy
case provider.EventDone:
// koniec, mamy usage stats
}
}
Agentic loop — pełny cykl konwersacji
Mechanizm jest prosty: agent wysyła Chat() z historią i listą narzędzi, konsumuje stream odpowiedzi i patrzy co dostał. Jeśli model zwrócił tekst — cykl się kończy, wynik leci na terminal. Ale jeśli w odpowiedzi pojawił się blok tool_use, to znaczy że model potrzebuje czegoś z zewnątrz — chce przeczytać plik, sprawdzić strukturę katalogów, odpytać MCP serwer. Agent wykonuje żądane narzędzie, dokłada wynik do messages[] i wywołuje Chat() od nowa. Model widzi teraz dłuższą historię — swoją poprzednią odpowiedź plus rezultat narzędzia — i decyduje co dalej. Może odpowiedzieć, może poprosić o kolejne narzędzie.
W kodzie to dosłownie for {} w turn.go:
for {
req := provider.ChatRequest{
System: a.system,
Messages: a.messages,
Tools: a.toolDefs(),
}
ch, err := a.provider.Chat(turnCtx, req)
// ...
result := a.consumeStream(ch, spin)
// budujemy wiadomość asystenta z tekstem i/lub tool calls
a.messages = append(a.messages, assistantMsg)
// brak tool calls → koniec cyklu
if len(result.toolCalls) == 0 {
return nil
}
// są tool calls → wykonaj i wróć na górę pętli
for _, tc := range result.toolCalls {
a.executeTool(turnCtx, tc)
}
}
Każda iteracja to osobny request HTTP z pełną historią konwersacji. Historia rośnie — messages[] to akumulowana pamięć, w której model widzi cały przebieg cyklu. Co się dzieje po wykonaniu narzędzia? executeTool() wywołuje je przez rejestr i dokłada wynik do historii:
result, err := a.registry.Execute(ctx, tc.name, params)
// ...
a.appendToolResult(tc.id, result, false)
Gdzie appendToolResult to po prostu:
func (a *Agent) appendToolResult(toolUseID, content string, isError bool) {
msg := provider.NewToolResultMessage(toolUseID, content, isError)
a.messages = append(a.messages, msg)
}
Przy następnej iteracji pętli model widzi tę wiadomość i decyduje — odpowiedzieć, czy poprosić o kolejne narzędzie.
Pętla wykonuje się aż model przestanie prosić o narzędzia. Przy dłuższych sesjach przepełnieniu kontekstu zapobiega kompresja (/compact ręcznie, albo automatycznie gdy przekroczymy 80% okna). Model może też w jednym przebiegu zażądać kilku narzędzi naraz (parallel tool calls) — consumeStream() akumuluje je wszystkie w tablicy calls, agent wykonuje po kolei, a wyniki lecą do modelu w jednym zapytaniu.
Jak to wygląda w praktyce
Powyższy proces ilustują logi zapisywane w ~/.go-agent/agent.log. Np. dla zapytania: "opisz plik @main.go":
# startup: config + MCP
time=2026-03-28T17:45:16.248+01:00 level=DEBUG msg="config loaded" provider=ollama
time=2026-03-28T17:45:16.248+01:00 level=INFO msg="mcp connecting" server=context7
time=2026-03-28T17:45:17.380+01:00 level=INFO msg="mcp ready" servers=2 tools=3
# cykl zaczyna się — 1 wiadomość w historii (samo zapytanie), 9 narzędzi w tools[]
time=2026-03-28T17:45:33.588+01:00 level=DEBUG msg="turn start" messages=1 query="opisz plik @main.go"
time=2026-03-28T17:45:33.610+01:00 level=INFO msg="[flow] → request" provider=ollama messages=1 tools=9
# model odpowiada po ~8s: chce wywołać read_file z argumentem {"path":"main.go"}
time=2026-03-28T17:45:41.352+01:00 level=INFO msg="[flow] ← tool_call" name=read_file id=ollama_1
time=2026-03-28T17:45:41.353+01:00 level=INFO msg="[flow] input" json="{\"path\":\"main.go\"}"
# agent wykonuje narzędzie — plik ma 11 linii
time=2026-03-28T17:45:41.371+01:00 level=DEBUG msg="tool call" name=read_file path=main.go
time=2026-03-28T17:45:41.371+01:00 level=INFO msg="[flow] executing" name=read_file
time=2026-03-28T17:45:41.463+01:00 level=DEBUG msg="tool done" name=read_file lines=11 preview="package main"
time=2026-03-28T17:45:41.463+01:00 level=INFO msg="[flow] result" name=read_file length=122
# wynik trafia do messages[] i agent wysyła drugi request — teraz 3 wiadomości: zapytanie + odpowiedź modelu + wynik narzędzia
time=2026-03-28T17:45:41.463+01:00 level=INFO msg="[flow] → result" tool_id=ollama_1 is_error=false
time=2026-03-28T17:45:41.502+01:00 level=INFO msg="[flow] → request" provider=ollama messages=3 tools=9
# model odpowiada tekstem — cykl skończony: 1 tool call, 6444 tokenów wejścia
time=2026-03-28T17:45:46.876+01:00 level=DEBUG msg="turn done" tool_calls=1 in=6444 out=280
time=2026-03-28T17:45:46.876+01:00 level=INFO msg="[flow] ← response" chars=720
Cała reszta
Powyższe sekcje opisują trzon — protokół, streaming, pętlę agenta. Jednak dla nawet podstawowego prototypu potrzeba znacznie więcej:
Providery i przełączanie w runtime. Agent obsługuje Anthropic, OpenAI i Ollamę za wspólnym interfejsem. Providera i model można zmienić w trakcie sesji (/provider, /model) — bez restartu, bez utraty historii.
Sesje. Każda rozmowa zapisywana jest jako plik JSONL z metadanymi (katalog roboczy, provider, tytuł wygenerowany przez LLM). Sesję można wznowić (/resume) — agent odtwarza historię i wyświetla kompaktowe podsumowanie poprzednich kroków.
Kompresja kontekstu. Przy dłuższych sesjach messages[] rośnie aż zaczyna wypełniać context window. Gdy przekroczy 80%, agent automatycznie prosi model o streszczenie starszych wiadomości — pilnując przy tym żeby nie rozdzielić par tool_use/tool_result. Jest również możliwość wywołanie ręcznego przez komendę /compact.
Potwierdzenie wykonania. Potencjalnie destrukcyjne narzędzia (bash, edit, write_file) wymagają potwierdzenia od użytkownika zanim agent je wykona. Jedno naciśnięcie klawisza: y / n / a (approve all).
Filtrowanie <think>. Niektóre modele (np. Qwen Coder) generują bloki <think>...</think> z wewnętrznym tokiem rozumowania. Agent zapisuje je w historii, ale nie wyświetla na terminalu.
TUI. Warstwa prezentacji oparta na Bubble Tea — wielolinijkowy input, podświetlanie składni (Chroma), renderowanie markdowna (Glamour), interaktywny picker do wyboru modeli i sesji, spinner z paskiem statusu.
MCP, skills, AGENT.md. Agent może podłączyć zewnętrzne serwery narzędzi przez MCP, załadować gotowe zestawy instrukcji (skills) z plików SKILL.md, i wstrzyknąć kontekst projektowy z AGENT.md do system promptu.
Kod projektu dostępny na Githubie github.com/baniol/go-agent