Refactoring for Autonomy: Building a Generic Agent Engine in Go
How I leveraged Go Generics to move from a brittle 'Push' model to a clean, reusable 'Pull' engine for my AI agents.
When I first started giving my AI agents agency, I moved them from a Push model—where I statically injected data into their prompts—to a Pull model, where they decide what data they need and fetch it themselves. I documented that initial transition in From Push to Pull: Giving My AI Agents Agency.
But as I added more specialized agents like the PlanReviewer, I found myself drowning in a sea of duplicated boilerplate. Every agent needed the same "While-Loop" logic to handle the turn-taking between the LLM and the Go tools. I realized that if I wanted to scale my autonomous squad, I needed a cleaner, more generic abstraction.
I solved this by building a Generic Agent Engine in Go.
The Solution: A Generic Execution Loop
Instead of each agent managing its own multi-turn state, I extracted the core conversational logic into a single, reusable function powered by Go 1.18+ Generics. This allows the agent to handle tool calls and side effects (like retrieving recipes) with total type safety.
Before: Duplicated Boilerplate
Initially, every agent had a custom, hardcoded loop like this:
func (a *Analyst) executeLoop(ctx context.Context, chat llm.Conversation) (llm.ContentResponse, error) {
for {
resp, err := a.llm.GenerateContent(ctx, chat, tools)
if err != nil {
return llm.ContentResponse{}, err
}
chat = append(chat, resp.Message)
if !resp.Message.IsAToolCall() {
break // Exit loop on final JSON
}
// Hardcoded tool logic...
toolCall := resp.Message.ToolCalls[0]
results, _ := a.handleSearch(ctx, toolCall)
chat = append(chat, results)
}
return resp, nil
}
After: The Generic Engine
By introducing a generic type T for "side effects," I created a single engine that any agent can hire.
// The engine doesn't care WHAT the tool returns, just THAT it returns something of type T.
func ExecuteAgentLoop[T any](
ctx context.Context,
generator llm.TextGenerator,
chat llm.Conversation,
tools []llm.Tool,
handlers map[string]ToolHandler[T],
) (llm.ContentResponse, []T, []shared.ToolCallMeta, error) {
// ... turn-taking logic ...
}
Architectural Shift: From Hardcoded to Handler-Based
The real power of this refactor isn't just the reduced lines of code; it's the decoupling.
I moved from a world where the agent was responsible for the execution of the loop, to a world where the agent only defines its Handlers. The ExecuteAgentLoop handles the "orchestration," and the agent provides the "implementation."
graph TD
A[Agent: Analyst] -->|Provides Handlers| B[Generic Engine]
C[Agent: PlanReviewer] -->|Provides Handlers| B
B -->|Turn 1| D[LLM: Tool Call]
D -->|Tool Call| E[Go: Execute Handler]
E -->|Tool Response| B
B -->|Turn 2| F[LLM: Final Result]
Why This Matters for My AI Squad
This refactor achieved three critical things for my meal planner:
- Strict Type Safety: Using
ExecuteAgentLoop[[]recipe.Recipe]ensures that my side effects are always correctly typed without usinganyand casting later. - Autonomous PlanReviewer: The PlanReviewer is now a first-class citizen in the "Pull" world. It no longer needs me to search for recipes upfront; it decides if the user's feedback requires a search and pulls the data itself.
- DRY Tooling: I moved the
HandleRecipeSearchlogic into a shared helper inengine.go. Now, if I improve how I search for recipes, every agent in my squad gets smarter instantly.
The Pragmatic Evolution
This wasn't an "over-engineered" decision I made on day one. It was a response to the friction I felt while adding the third agent to the system. As I described in Scaling My AI Personas, my workflow is about solving frictions as they arise.
Building a generic engine might seem like "more work" initially, but it's the foundation that allows my agents to move from simple "chatbots" to a truly Autonomous Squad.