Build a bounded agent interface
Language: English | 简体中文
This advanced tutorial assembles a streaming agent CLI without moving model, tool, or product grammar into the framework. The resulting interface keeps ordered text bounded, routes discrete domain events on the owner goroutine, reviews tool calls, and releases finished history to terminal scrollback.
Complete implementation: examples/agent
Before you begin
Read these guides in order:
- Compose a themeable picker for headless ownership
- Render Markdown, code, and mathematics for passive content
- Build bounded streaming output for byte ingress and publication
The example uses a deterministic mock backend. Replace it with a model or process adapter in the application; the interface architecture stays the same.
Define the application boundary first
Keep model concepts in the application. Continuous answer text is an io.Writer; sparse transitions use named methods because they are not bytes.
type Backend interface {
Run(context.Context, string, Output) error
}
type Output interface {
io.Writer
Step(context.Context, StepUpdate) error
Review(context.Context, ChangeProposal) (bool, error)
Tool(context.Context, ToolResult) error
}This boundary has no frames, components, themes, or terminal types. A backend can be tested with an ordinary fake and can run outside a TUI.
Do not encode review requests or plan transitions inside the text stream. Bytes may be chunked at any boundary; domain events need identity, acknowledgement, and typed results.
Give each lifetime an owner
An agent run crosses five ownership regions:
| State | Owner | Lifetime |
|---|---|---|
| Model request and transport | Backend goroutine | One run |
| Accepted answer bytes | program.ByteIngress | Until owner delivery |
| Open Markdown tail | Application owner goroutine | Until the block finishes |
| Live transcript and review | Headless tree | While interactive |
| Committed rows | Terminal | Rest of the shell session |
Only the backend writes concurrently. Every component and application entity mutates on the runtime owner goroutine.
Model the conversation as an entity
Keep the invariant together: transcript content, scroll, selection, sticky prompts, Markdown stream, and open block all describe one conversation lifetime.
type conversation struct {
content headless.Transcript
scroll headless.Scroll
selection headless.Selection
sticky headless.Sticky
view kit.Transcript
stream markdown.Stream
open *markdown.Doc
openID headless.BlockID
hasOpen bool
}Methods such as Markdown, FlushMarkdown, Append, Retain, and Reset belong on this entity. A free function that changes half these fields would split the lifetime rule across callers.
Start one bounded run
Create ingress on the owner goroutine, then hand its writer end to the backend adapter. The byte limit is an application policy based on expected chunking and acceptable memory.
ingress, err := program.NewByteIngress(program.ByteIngressConfig{
Dispatcher: runtime.Dispatcher(),
Limit: 64 << 10,
Consume: agent.accept,
})
if err != nil {
agent.finishRun(err)
return
}
ctx, cancel := context.WithCancel(context.Background())
run := &agentRun{cancel: cancel}
agent.run = run
output := &agentBridge{
ingress: ingress, dispatch: runtime.Dispatcher(),
owner: agent, run: run,
}Start the backend in one goroutine and close ingress with the backend's terminal error. CloseWithError preserves every accepted byte before the final batch.
go func() {
err := backend.Run(ctx, prompt, output)
_ = ingress.CloseWithError(err)
}()Bridge discrete events with acknowledgement
Call Dispatcher.Invoke from a worker goroutine when a sparse domain transition needs completion acknowledgement. It uses the same FIFO as Post. Cancellation removes an unclaimed callback; once the owner claims it, Invoke waits for completion and returns the callback's error. A returned cancellation therefore leaves no callback that can later commit state. Completion does not mean the requested frame has reached the terminal.
func (b *agentBridge) post(ctx context.Context, fn func()) error {
return b.dispatch.Invoke(ctx, func() error {
if b.owner.run != b.run {
return context.Canceled
}
fn()
return nil
})
}The run identity rejects a late event from a cancelled backend after another run has started. The acknowledgement also preserves ordering between earlier ingress work and the transition that follows it. A stopped or zero dispatcher returns program.ErrStopped for unclaimed work. A callback that panics returns program.ErrInvocationAborted to the worker while the original panic continues through program.Run; abnormal goroutine exit also releases the worker with that error. Do not call Invoke from a component callback, because the owner would wait for itself. Use Post when no completion result is needed.
Turn answer chunks into stable blocks
The ingress callback runs on the owner. Feed bytes into markdown.Stream, finish stable blocks once, and replace only the open document.
func (a *agent) accept(batch program.ByteBatch) {
if len(batch.Data) > 0 {
a.conversation.Markdown(string(batch.Data))
a.conversation.Retain(a.runtime)
}
if batch.Final {
a.finishRun(batch.Err)
}
}An open block remains mutable because another chunk can change its meaning. A stable block is marked finished and never parsed again.
Release finished history
Keep a small interactive window for selection and search. Commit older finished blocks through kit.Transcript.Commit; the same measured drawable is printed and then removed from live ownership.
const retainedBlocks = 8
func (c *conversation) Retain(printer kit.Printer) {
finished := 0
for i := range c.content.Len() {
id := c.content.FirstBlock() + headless.BlockID(i)
if !c.content.Finished(id) {
break
}
finished++
}
if excess := finished - retainedBlocks; excess > 0 {
c.view.Commit(printer, excess)
}
c.scroll.ToBottom()
}Do not keep a second copy of committed rows for redraw. The terminal owns them now. Live memory and resize work must depend on the active tail, not session age.
Make tool review a domain handshake
A review request blocks the backend, not the interface owner:
- The bridge posts a request to the owner
- The owner opens a
headless.Forminside aheadless.Stack - Approval or denial sends one value on the request channel
- The backend resumes or stops the proposed operation
type reviewRequest struct {
proposal ChangeProposal
answer chan bool
}
func (a *agent) answerReview(approved bool) {
request := a.review
if request == nil {
return
}
a.review = nil
a.reviewDialog.Controller().Dismiss()
request.answer <- approved
}Render a proposed change with core/diff and kit.Diff. Keep permission scope, tool names, and the effect of approval in application types. They are product grammar, not reusable terminal behavior.
Settle every terminal path
One method should settle a run for success, cancellation, backend failure, and runtime shutdown:
- Flush the Markdown tail
- Publish or retain every finished block according to policy
- Stop timers and progress updates
- Deny any unanswered review
- Cancel the backend context
- Clear the current run identity
- Present the final status on the owner goroutine
Treat terminal write failure separately from backend failure. A model may finish successfully while the terminal cannot accept the final presentation.
Test the invariants, not the mock prose
Use three boundaries:
| Harness | Prove |
|---|---|
| Backend unit test | Cancellation, event order, review result, short writes |
programtest | Visible plan, completion, review, cancellation, final state |
ptytest | Scrollback publication, resize, idle output, terminal cleanup |
Run the repository's advanced slice:
cd examples
go test ./agent
go test -update ./agent # only when reviewing intended golden changesThe completed application should satisfy one decisive property: doubling a finished session does not double the live transcript. Read Architecture for the normative lifetime, dependency, failure, and v1 release rules behind that property.