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.

Refactoring for Autonomy: Building a Generic Agent Engine in Go
Photo by Spencer Watson / Unsplash

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:

  1. Strict Type Safety: Using ExecuteAgentLoop[[]recipe.Recipe] ensures that my side effects are always correctly typed without using any and casting later.
  2. 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.
  3. DRY Tooling: I moved the HandleRecipeSearch logic into a shared helper in engine.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.

References & Resources