Chapter 2 — Speak Model
# Chapter 2 — Speak Model
Last chapter ended with a leak. Asked to explain a function, the model
answered in prose that looked like a tool call —
{"thought":…,"action":"file_list","tool":"file_list",…} — and the runtime
closed the run having done nothing. Four seconds, twenty-nine tokens, no
work.
Here is the builder’s translation of that failure: the runtime never reads the model’s mind. It consumes a structured decision, and the decision was not structured. The seam between “what the model said” and “what the agent can act on” is a Go interface, it has six normative rules about streaming, and this chapter puts you on the implementing side of it. By the end you will have written a model — a scripted, deterministic, table-driven fake — that satisfies the same contract the Ollama transport satisfies, and every remaining chapter of this book will drive the critic through it.
Why a fake? Because a loop you cannot control is a loop you cannot test. The book’s thesis is that behavior must be pinned before it can be trusted; a model with a temperature knob is the opposite of pinned. Your fake will do exactly what its script says: emit these chunks, return this tool call, fail on command, cancel when told. When a test passes, it will be a fact about the agent.
# Where you are
Chapter 1 left you with a green baseline (go build, go vet,
make lint-arch), a reset stringkit, and one governed turn’s receipts. All
of that stays standing; this chapter adds one new directory to the tree —
and even that only inside a scratch clone, by the end. Nothing here needs a
running model, a sandbox, or stringkit. It is the one chapter you could
write on a train.
# Technical requirements
Nothing new. Go (go directive 1.26.0, toolchain go1.27.2), the Chapter 1
checkout, and the devdocs/agent-construction-relurpify/labs/ folder from
the repository. No model, no Docker, no Python.
# The contract, read closely
Open model/ports.go. The whole provider seam starts at one interface:
// LanguageModel is the primary contract every LLM backend satisfies.
type LanguageModel interface {
Generate(ctx context.Context, prompt string, options *LLMOptions) (*LLMResponse, error)
GenerateStream(ctx context.Context, prompt string, options *LLMOptions) (<-chan string, error)
Chat(ctx context.Context, messages []Message, options *LLMOptions) (*LLMResponse, error)
ChatWithTools(ctx context.Context, messages []Message, tools []LLMToolSpec, options *LLMOptions) (*LLMResponse, error)
}Four methods, and the shape of the whole framework falls out of them. The
single-prompt methods (Generate, GenerateStream) exist for thinking
models driven by a compiled context. The conversation methods (Chat,
ChatWithTools) exist for the paradigm loop, where a turn history is the
actual interface. The tools []LLMToolSpec parameter is what Chapter 1’s
leak was about: when the runtime asks a model to decide among tools, it
sends the schemas and expects a structured ToolCall back — not prose.
Three types carry the vocabulary, and each has one line worth memorizing:
LLMResponseis the decision envelope.Textis the content;Thinkingis the reasoning channel — the doc comment is emphatic that a turn may spend its whole response there and that the reasoning is “first-class material for the consumer, never waste”;FinishReasonsays why generation stopped;ToolCallscarries the structured decisions, each anID, aName, andArgs.Messageis one conversation turn:Role,Content, and — for feeding tool results back —ToolCallID.LLMOptionsis per-invocation configuration:Model,Temperature,MaxTokens,Stopsequences,ContextWindow(the doc comment explains that zero means the transport derives it from the configured budget, never the server’s silent default), andStreamCallbackfor consumers that want tokens pushed as well as pulled.
# The six rules of streaming
GenerateStream returns <-chan string — a channel, not a callback — and
the interface’s doc comment spells out six normative rules. Normative means
every implementation must satisfy them and every consumer may rely on them;
when your fake is written, your critic will be one of those consumers.
Condensed to their spine:
- R1 — The call-time context is the only stop signal. An implementation must not need the consumer’s cooperation to stop; abandoning a stream means cancelling a context.
- R2 — On cancellation, internal goroutines exit and the channel closes within 250 ms.
- R3 — The channel is closed exactly once, by its producer.
- R4 — Resources the pump owns (HTTP bodies, files) are closed by that pump, exactly once, including on cancellation.
- R5 — Producer-side encode/write failures degrade — drop, count, emit telemetry — and must not panic.
- R6 — Send contention resolves by selecting on
ctx.Done(); the token is dropped and counted when the context is done.
Read R1 and R6 together and you can see the design taste of this codebase: no consumer-side stopping protocol, no blocking producers, no silent deadlock. Every failure path degrades into something countable. You will enforce these rules in tests before this chapter ends.
# The seam around the port
model/runtime.go adds the vocabulary the app uses to hold a model:
ModelBackendwraps aLanguageModelwith lifecycle (Close) and debug controls. Execution and workspace-session code see the backend, not the provider.EventSinkis the emit port for model-level events, and its doc comment is a layering lesson in itself: “Deliberately not telemetry.Telemetry: model must not gain a DAG edge to the telemetry domain — providers emit plain events and the composition root adapts.” The model domain is a leaf; it does not know the telemetry package exists. Chapter 3 makes that kind of statement mechanically enforced.ModelFactoryandModelProductare how the composition root hands a workspace a model wrapped in app-level instrumentation.
And one more interface earns its place on the wall,
ProfiledModel (ports.go:80):
type ProfiledModel interface {
LanguageModel
ToolRepairStrategy() string
MaxToolsPerCall() int
UsesNativeToolCalling() bool
}There is Chapter 1’s leak, named in code. A model profile declares how
tool decisions arrive — UsesNativeToolCalling is the intent: native
path where the model’s template speaks tool calls natively; prompt_based
asks for a structured decision in prose and parses it. ToolRepairStrategy
returns "heuristic-only" or "llm" — whether a malformed decision gets
repaired locally or by re-asking the model.
# Decision repair, from the implementer’s seat
That repair loop is real code, and you read part of it in Chapter 1’s
telemetry. When the react paradigm’s think node gets a response, it
normalizes the payload (cognitionzoo/react/react_think_node.go,
normalizeDecision): thinking-channel or content, structured tool calls
or prose, finish_reason consulted. If the decision is unparseable,
repairDecision re-prompts the model — with the JSON schema and the
original raw response appended, so the model can see its own mistake —
bounded by the profile’s repair strategy. The lesson for you is not the
regexes; it is that the parseable decision is a contract. Your critic
will produce decisions too, and it will be driven by a model that always
produces parseable ones — because you will write that model yourself, in
about a hundred lines.
# Hands-on
# 1. The scratch-clone convention
From here on, the book’s Go code lives in one self-contained sub-package,
cognitionzoo/critic/ — the critic and everything that supports it. You
never edit the repository’s existing files to make it work (one exception,
Chapter 9’s registration, which is the point of that chapter), and your
working tree stays clean: verify.sh applies the chapter overlay to a
scratch clone and runs there.
The overlay for this chapter is
devdocs/agent-construction-relurpify/labs/testdata/overlay/ch02-fake-model/,
containing two files at their true repository paths:
cognitionzoo/critic/fake_model_impl_test.go # the fake (package critic)
cognitionzoo/critic/fake_model_contract_test.go # the contract tests (package critic_test)Notice the file names: both are _test.go files. Hold that thought — the
gate will explain it before this chapter ends.
Run the tier-1 verifier; it clones, applies the overlay, builds, vets, and tests:
./verify.sh build ch02== tier 1 (build): ch02 ==
ok codeburg.org/lexbit/relurpify/cognitionzoo/critic 0.057s
[PASS] build: ch02 compiles clean and its tests pass(The labs directory lives inside the repository checkout; verify.sh is
at devdocs/agent-construction-relurpify/labs/verify.sh.)
# 2. Read the fake before you trust it
The fake is small enough to read whole, and you should — every later chapter stands on it. The center of the design is one scripted turn:
type FakeTurn struct {
Chunks []string // streamed in order by GenerateStream
Text string // non-streaming text; empty means "join Chunks"
Thinking string
FinishReason string // defaults to "stop"
ToolCalls []model.ToolCall
}and the model that replays turns in order, cycling back to the first when it runs out — so a bounded loop can be driven turn by turn:
type FakeModel struct { /* turns, next, err, pace, counters */ }
var _ model.LanguageModel = (*FakeModel)(nil) // compile-time proofThat one line — var _ model.LanguageModel = (*FakeModel)(nil) — is the
contract test before any test runs: if the fake drifts from the interface,
the package does not compile.
The streaming half is where the six rules live. The pump is the only code that touches the channel, and it closes it exactly once (R3), on every path:
func (f *FakeModel) pump(ctx context.Context, turn FakeTurn, out chan string) {
defer close(out)
for _, chunk := range turn.Chunks {
if f.paceOf() > 0 {
select {
case <-ctx.Done():
return
case <-time.After(f.paceOf()):
}
}
select {
case out <- chunk:
case <-ctx.Done():
f.noteDrop() // R6: dropped and counted
return
}
}
}Two controls make tests deterministic: SetPace(d) inserts a pause before
each chunk (so you can cancel mid-flight), and SetError(err) makes every
method fail on command (so you can test degradation). Counters — Calls,
LastInput, DroppedTokens — are what later chapters assert against.
# 3. The contract tests enforce the rules that matter
The external test file (package critic_test) drives the fake through its
public surface — deliberately, because that is what every consumer gets.
Nine tests, and two of them are the streaming contract:
The R2 test cancels mid-stream and holds the implementation to the 250 ms budget:
<-ch // first chunk arrived; abandon the stream
cancel()
// R2: the channel closes within 250 ms of cancellation.
closed := make(chan struct{})
go func() {
for range ch {
}
close(closed)
}()
select {
case <-closed:
case <-time.After(250 * time.Millisecond):
t.Fatal("channel did not close within 250 ms of cancellation")
}The R6 test starts a stream nobody reads; the producer blocks on its first send until the context ends, then drops and counts:
time.Sleep(30 * time.Millisecond)
cancel()
for range ch {
}
if got := fm.DroppedTokens(); got != 1 {
t.Fatalf("DroppedTokens() = %d, want 1", got)
}The rest: scripted chunks arrive in order; Generate joins them into
Text and honors FinishReason; ChatWithTools returns the scripted
ToolCall untouched (the fake never invents decisions); turns cycle in
order so a loop can be driven; error injection fails all four methods with
the same error; an empty script fails cleanly rather than panicking.
Run it:
go test ./cognitionzoo/critic/... -count=1 -v=== RUN TestFakeModelIsALanguageModel
--- PASS: TestFakeModelIsALanguageModel (0.00s)
=== RUN TestGenerateStreamEmitsScriptedChunksInOrder
--- PASS: TestGenerateStreamEmitsScriptedChunksInOrder (0.00s)
=== RUN TestGenerateReturnsJoinedText
--- PASS: TestGenerateReturnsJoinedText (0.00s)
=== RUN TestChatWithToolsReturnsScriptedToolCalls
--- PASS: TestChatWithToolsReturnsScriptedToolCalls (0.00s)
=== RUN TestTurnsCycleInOrder
--- PASS: TestTurnsCycleInOrder (0.00s)
=== RUN TestGenerateStreamClosesOnCancellation
--- PASS: TestGenerateStreamClosesOnCancellation (0.03s)
=== RUN TestGenerateStreamDropsAndCountsOnSendContention
--- PASS: TestGenerateStreamDropsAndCountsOnSendContention (0.03s)
=== RUN TestErrorInjectionFailsEveryMethod
--- PASS: TestErrorInjectionFailsEveryMethod (0.00s)
=== RUN TestEmptyScriptFailsCleanly
--- PASS: TestEmptyScriptFailsCleanly (0.00s)
PASS
ok codeburg.org/lexbit/relurpify/cognitionzoo/critic 0.057sFifty-seven milliseconds, no network, no model, no sandbox — and the two hardest rules of the streaming contract proven, not assumed.
# 4. The gate has opinions about your package
The first draft of this chapter’s package did not survive the gates, and
the autopsy is worth ten pages of theory. The draft put the fake in
fake_model.go — a production file — in a package nothing imported yet.
make lint-arch:
[FAIL] consumer: 2 violation(s)
consumer: codeburg.org/lexbit/relurpify/cognitionzoo/critic has no importers(The second violation came from leaving .go files inside the labs folder
itself; the overlay now lives under testdata/, which the toolchain —
and the gates — ignore.)
Read the gate’s own comment
(tooling/arch/consumer_check.go):
a package consumed only by tests is still load-bearing infrastructure here. Packages with zero importers of any kind are dead code.
That is not bureaucracy; that is the same no-dead-code taste that makes
the charter ban stubs. An unimported package is a stub the linker would
skip. The fix is not an exemption — you never add one of those — it is
putting the code where it belongs: until something consumes the critic
package in production (Chapter 6’s runner command becomes its first
importer), every file it contains is a _test.go file. The gate skips
test-only packages by design, and nothing honest is lost: test helpers
are test code in Go.
Chapter 3 turns this from one instructive failure into the full law of the tree — including why the AST gates did not even look at your test file when they scanned 1,087 others.
# Gotchas
var _ model.LanguageModel = (*FakeModel)(nil)is load-bearing. Delete it and the fake can drift from the contract silently; the tests would keep passing against a narrower interface than consumers use.SetPaceexists for cancellation tests only. With pace zero the pump finishes before your test can cancel anything; if your cancel test flakes, you forgot the pace, not the contract.- The fake cycles its script. Three calls against a two-turn script
replay turn one. Assert
Calls()when order matters; do not assume the script is consumed once. Thinkingis notText. A scripted turn that puts everything inThinkingand nothing inChunksis a legal model response — the contract says so — and your consumer code must decide what to do with it. Chapter 5’s nodes will have opinions.- Test files never reach production. If you later find yourself
wanting
FakeModelin non-test code, that is the Chapter 6 conversation about the runner — not a reason to rename the file.
# Summary
model.LanguageModelis four methods and six normative streaming rules; every provider, wrapper, and your fake satisfy it, and every consumer — including the critic you are building — may rely on the rules without checking.- The decision is data:
LLMResponse.ToolCallsfor structured tool decisions,Thinkingas first-class reasoning-channel output,FinishReasonfor the why. Chapter 1’s leak was a decision that never became data;ProfiledModel.ToolRepairStrategynames how the framework repairs one that arrives malformed. EventSinkis deliberately not the telemetry interface: the model domain is a DAG leaf, and even its event vocabulary is chosen to keep that edge from existing.- The fake model is a hundred lines, deterministic, and testable in 57 ms; from Chapter 4 on, every graph execution in this book runs on it.
- The consumer gate rejected the first package layout — production code with no importer — and the fix was to write test code in test files. The gates teach; your job is to read what they say.
# Check yourself
- Why does
GenerateStreamreturn a channel rather than taking a callback, and which of the six rules would be hardest to keep if it took one? - Your scripted turn has
Thinking: "…"and noChunks. What doesGeneratereturn asText, and what does that tell you about the difference between the reasoning channel and content? - What does
ProfiledModel.ToolRepairStrategy()control, and where did you already watch a decision fail to parse? - The consumer gate failed the first package layout. Quote the rule it
enforces and explain why the fix was moving code into
_test.gofiles rather than adding an exemption. EventSinkis nottelemetry.Telemetry. What edge is that choice protecting, and which chapter’s subject makes that protection mechanical?
Answers. 1. Ownership: the producer owns the channel and its closing;
with a callback, cancellation semantics would need a consumer protocol,
violating R1 — R3’s exactly-once close would be the hardest to keep. 2.
Empty string — Text is empty when Chunks is empty; the reasoning went
to the thinking channel, which is first-class material a consumer may
render, log, or spend, never waste. 3. Whether a malformed decision is
repaired heuristically or by re-prompting the model with the schema and
the raw response; the Chapter 1 leak, where the decision arrived as prose
and the run closed without executing anything. 4. A package with zero
importers is dead code, and test-only importers count as consumption; test
helpers belong in _test.go files, so the package was legitimately
test-only until a production importer exists — no exemption needed, and
exemptions are load-bearing anyway. 5. The model → telemetry import
edge in the domain DAG; Chapter 3, where domaincheck enforces import
direction mechanically.
# Try it yourself
- Slow first token. Write a test (in
fake_model_contract_test.go) with a two-chunk turn atSetPace(20ms)whose consumer renders a “typing…” marker before the first chunk and asserts it appears exactly once. You are practicing the consumer side of R1: cooperate with cancellation, never with stopping. - Degradation, witnessed.
SetErroron the fake, then write a consumer that drainsGenerateStreamand counts nothing: assert that the error surfaces from the call, the channel is nil-or-closed, and no panic escapes. You have just written the shape of R5’s consumer.
# Where next
You can now speak model — and you own the only model in the book that always says what it means. Next chapter the lens pulls back: the whole tree as law. The domain DAG, the AST gates that read 1,087 files in three seconds, the charter’s four bans, and the envelope that threads every graph — ending with a deliberate violation, planted in production code, caught by exact file and line, and reverted while the gate watches. Bring your new favorite failure: the consumer gate is only the first gate with opinions.
The lab lives at
devdocs/agent-construction-relurpify/labs/ — verify.sh build ch02
re-runs this chapter’s checks from nothing, and
transcripts/ch02-critic-package.md holds the captured output, including
the consumer-gate failure this chapter walked through.