Reading agentloop in Go: Immutable Agents, Per-Call Options, and Shell Process Groups
Most agent libraries hand you an object you quietly stop sharing between goroutines after the first race detector hit. The Agent doc comment in smhanov/agentloop claims otherwise: “An Agent is immutable after construction and safe for concurrent use; per-run state lives in locals.” That sentence costs the library something at every API boundary, and the code shows where it pays.
agentloop runs the LLM tool-calling loop: send a prompt, let the model invoke tools, execute them, feed the results back, repeat until there’s a final answer. Provider access goes through llmhub. Below are the Go patterns I pulled out of agent.go, answer.go, and the bash preset, with a note on which ones I’d steal.
Functional options applied twice: construction and call
Functional options usually run once, in the constructor. agentloop runs them twice. New starts from a config of defaults (maxToolCalls, dupCallLimit, parallelTools, streamWatchdog, and a toolChoice of llmhub.AutoToolChoice()), calls applyOptions, then resolves skills.
Ask does this:
cfg := a.cfg
applyOptions(&cfg, opts)
return a.ask(ctx, query, cfg, nil)
That’s the core of the design. a.cfg is a struct value, so cfg := a.cfg copies its scalar fields. The doc comment pins the ordering: options “are applied after the constructor options, so later options win.” Options such as WithTools allocate a replacement slice, which keeps that per-call override away from the Agent.
Here’s the shape in isolation (illustrative, not copied from the project):
type config struct {
maxToolCalls int
systemPrompt string
}
// Option mutates a config. Applied at construction and again per call.
type Option func(*config)
func applyOptions(cfg *config, opts []Option) {
for _, opt := range opts {
opt(cfg)
}
}
type Agent struct {
cfg config // never mutated after New
}
func (a *Agent) Ask(ctx context.Context, query string, opts ...Option) (string, error) {
cfg := a.cfg // value copy keeps the Agent immutable
applyOptions(&cfg, opts)
return run(ctx, query, cfg)
}
The copy is shallow, which is also the caveat in this implementation. config contains slices and a settings map. WithTools copies its input, but WithGenerateOptions appends to the copied slice and WithSetting writes through the map already stored in the copied config. Those options need copy-on-write treatment before the concurrency claim can safely cover arbitrary per-call option combinations. agentloop does freeze skillIssues outside config, and both Tools() and SkillIssues() return copies, but that does not make every reference-bearing config field independent.
The accessors keep the same discipline. Tools() allocates a new slice and copies into it. SkillIssues() does the same, returning nil rather than an empty slice when there’s nothing to report. Both doc comments say “mutating it affects no Agent.” Any type advertising concurrency safety that hands back an internal slice is lying, so the copy is the price of the claim.
A non-nil result alongside a non-nil error
Ask returns (*Answer, error). Go instinct says ignore the first value when the second is non-nil. agentloop tells you not to:
A run that aborts mid-loop (transport, hook, or context error) returns the error alongside a partial StatusFailed Answer carrying the transcript so far — check err first, but never assume the Answer is nil on error.
Answer in answer.go is the trace: Text, ReasoningText, Steps, ToolCalls, Status, Usage, Timing, a Tools []ToolResult slice of every execution in order, and Messages with the full transcript. Each ToolResult carries the tool name, the provider call ID, the arguments as executed (“post-rewrite by BeforeToolCall”), the output string the model actually received, the handler error, and the handler’s wall time.
Drop that on the floor because err != nil and you’ve thrown away the only record of what the model was doing when things went sideways. The StatusFailed doc goes one step further: the partial transcript arrives with “its tail repaired for the next provider request — synthesized results for unanswered tool calls, then an abort notice.” Unanswered tool calls get stub results so the transcript stays usable as the basis for a follow-up request instead of being a dead artifact.
The four statuses: StatusCompleted (including the forced answer delivered under llmhub.NoToolChoice after the tool budget ran out), StatusBudgetExhausted when the MaxSteps ceiling hits first, StatusStopped for graceful exits from a BeforeGenerate hook that set Skip or from the duplicate-call limit, and StatusFailed. Budget exhaustion is both a status and an error wrapping ErrBudgetExhausted, which is the right call for “it finished, just not the way you wanted” — callers who care can check, callers who only check err still find out.
Extending options from a subpackage with a type alias
The bash package is a shell preset: an agent that runs commands in an Environment until it reports completion. It needs its own options, and callers shouldn’t have to juggle two option types to get them. One line solves it:
// Option composes core and preset options in a single variadic list.
type Option = agentloop.Option
Alias, not defined type. bash.Option and agentloop.Option are the same type, so core and preset options mix in a single variadic call. type Option agentloop.Option would have forced a conversion at every call site, and users would have hated it within a day.
Preset values ride through the core option list as string-keyed settings. bash/agent.go declares constants like settingEnvironment = "bash.environment" and settingStepLimit = "bash.stepLimit", and each With* constructor wraps its value with agentloop.WithSetting; the preset decodes them later via agentloop.Settings. The core loop ferries data it knows nothing about while the preset keeps a typed public API over an untyped bag. It’s a reasonable trade, though the type safety stops at the With* constructors, so a typo in a setting key is a runtime problem, not a compile-time one.
WithStepLimit has a detail I liked: the documented default of 0 means unlimited, values below 0 count as 0, and the core loop’s MaxSteps is separately “raised to max(StepLimit, 2*MaxToolCalls+2) as the hard safety net.” A user-facing limit with a ceiling the user can’t argue with.
Non-zero exit codes are results, not errors
The Environment interface in bash/environment.go draws its error boundary unusually carefully:
type Environment interface {
Execute(ctx context.Context, command string) (ExecResult, error)
}
The comment sets the rule. Non-zero exit codes are results, “not errors — the agent iterates on failing commands.” Environment-level failures (a timeout, a spawn error) are also results: ReturnCode of -1 with a non-empty ExceptionInfo. The error return is reserved for the caller’s context being canceled or the run otherwise aborting.
For an agent loop this is the whole game. A failed grep is information the model needs, not an exception that ends the run. Route every exec failure through Go’s error return and the loop has to re-classify them all anyway, by string matching or type assertion, to decide which ones are fatal. Pushing the classification into ExecResult keeps the control flow one line long: err != nil means stop, everything else goes back to the model as an observation. Similar error-boundary reasoning shows up in Agent Tincan’s exit codes.
Killing the whole process tree, not just the shell
LocalEnvironment.Execute resolves the shell once behind a sync.Once (bash, with sh as the fallback), caching shell and shellErr so the zero value stays usable. Then it stacks a per-command timeout on the caller’s context:
runCtx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, e.shell, "-c", command)
cmd.Dir = dir
cmd.Env = mergeEnv(os.Environ(), e.extraEnv)
Two comments in that file carry the real weight. The child goes into a new process group, because “the child becomes a group leader, so the whole tree can be killed with one signal to -pid.” And a waitDelay constant of 100ms “bounds how long os/exec waits for output pipes to close after the timeout kill, so grandchildren holding the pipes cannot stall Execute.”
Both failure modes are easy to hit and annoying to diagnose. Killing bash -c "foo | bar" kills bash; the pipeline keeps running and keeps writing. And after the kill, cmd.Wait sits there until every write end of the stdout pipe closes, which a surviving grandchild is in no hurry to do. os/exec exposes Cmd.Cancel and Cmd.WaitDelay for exactly these two holes, which is what the file uses. Here’s the technique on its own (Unix-only, illustrative):
cmd := exec.CommandContext(ctx, "/bin/bash", "-c", command)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// Signal the negative PID to reach the whole group.
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
// Don't block forever on pipes a surviving grandchild still holds.
cmd.WaitDelay = 100 * time.Millisecond
out, err := cmd.CombinedOutput()
When a command can leave descendants holding stdout or stderr open, a timeout without process-group cancellation and a bounded pipe wait can still leave the caller stuck.
One small thing: backticks in prompt templates
bash/prompt.go opens with a constant that exists only because of a Go language rule:
// fence is the markdown code-fence marker used in the templates and the
// action parser. Declared as a constant because Go raw string literals
// cannot contain backticks.
const fence = "```"
The prompt templates are markdown with fenced code blocks, written as raw string literals for the embedded newlines. Raw strings are delimited by backticks, there’s no escape for one inside, so the literal has to break and concatenate fence wherever a code fence appears. Small, ugly, unavoidable.
The templates themselves are text/template sources. The system template requires exactly one bash code block per response with a THOUGHT section before it, and says outright that breaking the format gets the response rejected. The task template supplies {{.Task}} along with a restate, gather, verify, record, submit workflow and a standing instruction that the agent runs unattended: every response ends with the next command or the submit marker, never an offer to elaborate.
The preset’s wire protocol, limits, and exit statuses descend from mini-swe-agent: a submit marker of COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT, observation truncation at 10,000 runes, and a stop after three consecutive malformed responses (ExitRepeatedFormatError). The exit status strings are kept verbatim “for compatibility,” which is the kind of constraint you inherit forever once you take it on.
None of the three patterns needs an LLM. Copy-on-call config works when every reference-bearing field is copied or treated as immutable. A trace-carrying partial result helps any long multi-step operation where the failure context matters. Process-group cancellation is useful for Unix services that launch shells or process trees, provided they also define what graceful shutdown should mean. For the protocol side of this same problem space, see building MCP servers in Go.