Building AI Agents in Go
If you’ve been following the AI space lately, you’ve probably heard the term “AI agents” tossed around a lot. But what exactly are they, and why should you care? More importantly, how can you build one in Go?
In this tutorial, we’re going to explore the world of AI agents, understand what makes them tick, and then build a fully functional agent framework from scratch using Go. By the end, you’ll have a solid understanding of how agents work and the knowledge to build your own.
What Are AI Agents Anyway?
Let’s start with the basics. An AI agent isn’t just a chatbot that responds to questions. A simple chatbot takes your input, sends it to a language model, and returns a response. That’s it.
An AI agent, on the other hand, is more like an autonomous system that can:
- Use tools to accomplish tasks (search the web, read files, run calculations)
- Reason about what tools to use and in what order
- Make decisions based on the results it gets
- Repeat until it completes a task or reaches a decision
Think of it like the difference between asking a friend a question (chatbot) versus giving a friend a project to complete (agent). The agent has to break down the work, figure out what resources it needs, and execute a plan.
The ReAct Pattern
The most popular framework for building agents is called ReAct, which stands for Reasoning + Acting. The pattern looks like this:
- Think: The agent receives a task and thinks about what to do next
- Decide: Based on its reasoning, it decides which tool (if any) to use
- Act: It calls the appropriate tool with the right parameters
- Observe: It observes the result of the tool call
- Repeat: It loops back to step 1 with the new information
This cycle continues until the agent has enough information to provide a final answer or determines that it can’t complete the task.
Task Input
|
v
[Reason about next action]
|
v
[Select tool or provide answer]
|
v
[Execute tool]
|
v
[Observe result]
|
v
[Update context]
|
v
[Loop or finish?]
Building an Agent Framework in Go
Let’s build a practical agent framework from scratch. We’ll keep it simple but functional.
Step 1: Define the Tool Interface
First, we need a way for our agent to understand what tools are available. We’ll create a Tool interface:
package agent
import (
"context"
)
// Tool represents a capability the agent can use
type Tool interface {
// Name returns the name of the tool
Name() string
// Description explains what the tool does
Description() string
// Execute runs the tool with the given input
Execute(ctx context.Context, input string) (string, error)
}
// ToolRegistry holds all available tools
type ToolRegistry struct {
tools map[string]Tool
}
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{
tools: make(map[string]Tool),
}
}
func (tr *ToolRegistry) Register(tool Tool) {
tr.tools[tool.Name()] = tool
}
func (tr *ToolRegistry) Get(name string) Tool {
return tr.tools[name]
}
func (tr *ToolRegistry) List() []Tool {
tools := make([]Tool, 0, len(tr.tools))
for _, tool := range tr.tools {
tools = append(tools, tool)
}
return tools
}
Step 2: Implement Some Simple Tools
Let’s create a few tools for our agent to use:
package agent
import (
"context"
"fmt"
"math"
"strconv"
"strings"
)
// CalculatorTool performs basic math operations
type CalculatorTool struct{}
func (c *CalculatorTool) Name() string {
return "calculator"
}
func (c *CalculatorTool) Description() string {
return "Performs basic math operations. Input format: 'add 5 3', 'multiply 10 2', etc."
}
func (c *CalculatorTool) Execute(ctx context.Context, input string) (string, error) {
parts := strings.Fields(input)
if len(parts) < 3 {
return "", fmt.Errorf("invalid input format")
}
operation := parts[0]
num1, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
return "", err
}
num2, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
return "", err
}
var result float64
switch operation {
case "add":
result = num1 + num2
case "subtract":
result = num1 - num2
case "multiply":
result = num1 * num2
case "divide":
if num2 == 0 {
return "", fmt.Errorf("division by zero")
}
result = num1 / num2
case "power":
result = math.Pow(num1, num2)
default:
return "", fmt.Errorf("unknown operation: %s", operation)
}
return fmt.Sprintf("%.2f", result), nil
}
// WebSearchTool simulates searching the web
type WebSearchTool struct{}
func (w *WebSearchTool) Name() string {
return "web_search"
}
func (w *WebSearchTool) Description() string {
return "Searches the web for information. Input: search query"
}
func (w *WebSearchTool) Execute(ctx context.Context, input string) (string, error) {
// In a real implementation, you'd call an actual search API
// For this tutorial, we'll return a mock result
return fmt.Sprintf("Search results for '%s': Found relevant information...", input), nil
}
// FileReaderTool reads file contents
type FileReaderTool struct{}
func (f *FileReaderTool) Name() string {
return "read_file"
}
func (f *FileReaderTool) Description() string {
return "Reads the contents of a file. Input: file path"
}
func (f *FileReaderTool) Execute(ctx context.Context, input string) (string, error) {
// In a real implementation, you'd read the actual file
return fmt.Sprintf("Contents of %s: [simulated file content]", input), nil
}
Step 3: Build the Agent Loop
Now comes the core of our agent - the loop that decides what to do:
package agent
import (
"context"
"encoding/json"
"fmt"
"log"
)
// Agent represents an AI agent that can use tools to complete tasks
type Agent struct {
tools *ToolRegistry
llmClient LLMClient
maxIterations int
}
// LLMClient represents a language model interface
type LLMClient interface {
// GenerateResponse sends a prompt to the LLM and gets back a response
GenerateResponse(ctx context.Context, prompt string) (string, error)
}
// ToolCall represents a decision by the agent to call a tool
type ToolCall struct {
ToolName string `json:"tool_name"`
Input string `json:"input"`
Thought string `json:"thought"`
}
// ToolResult holds the result of executing a tool
type ToolResult struct {
ToolName string
Output string
Error error
}
func NewAgent(tools *ToolRegistry, llmClient LLMClient) *Agent {
return &Agent{
tools: tools,
llmClient: llmClient,
maxIterations: 10,
}
}
// Run executes the agent loop
func (a *Agent) Run(ctx context.Context, task string) (string, error) {
conversationHistory := []string{task}
iterations := 0
for iterations < a.maxIterations {
iterations++
log.Printf("Iteration %d: Processing task", iterations)
// Build the prompt with context
prompt := a.buildPrompt(conversationHistory)
// Get response from LLM
response, err := a.llmClient.GenerateResponse(ctx, prompt)
if err != nil {
return "", fmt.Errorf("LLM error: %w", err)
}
log.Printf("LLM Response: %s", response)
// Try to parse a tool call from the response
toolCall, isFinalAnswer := a.parseResponse(response)
if isFinalAnswer {
// Agent has provided the final answer
return response, nil
}
if toolCall == nil {
// Couldn't parse a valid tool call
conversationHistory = append(conversationHistory, "Invalid response format. Please use JSON format with tool_name, input, and thought fields.")
continue
}
// Execute the tool
tool := a.tools.Get(toolCall.ToolName)
if tool == nil {
conversationHistory = append(conversationHistory, fmt.Sprintf("Tool '%s' not found. Available tools: %v", toolCall.ToolName, a.getToolNames()))
continue
}
toolResult, err := tool.Execute(ctx, toolCall.Input)
if err != nil {
conversationHistory = append(conversationHistory, fmt.Sprintf("Tool execution failed: %v", err))
continue
}
log.Printf("Tool '%s' executed successfully", toolCall.ToolName)
// Add result to conversation history
resultMessage := fmt.Sprintf("Tool '%s' returned: %s", toolCall.ToolName, toolResult)
conversationHistory = append(conversationHistory, resultMessage)
}
return "", fmt.Errorf("max iterations reached without completing the task")
}
func (a *Agent) buildPrompt(history []string) string {
toolDescriptions := a.getToolDescriptions()
prompt := `You are an AI agent that can use tools to complete tasks.
Available tools:
` + toolDescriptions + `
Instructions:
1. If you need information, use the appropriate tool
2. After using a tool, wait for the result and use it to continue
3. When you have enough information to answer the original question, provide the final answer
4. Always respond in JSON format with this structure:
{
"tool_name": "name_of_tool",
"input": "input_for_tool",
"thought": "Your reasoning"
}
5. When providing the final answer, use this format:
{
"final_answer": "Your complete answer here"
}
Conversation history:
`
for i, entry := range history {
prompt += fmt.Sprintf("%d. %s\n", i+1, entry)
}
prompt += "\nWhat should be your next action?"
return prompt
}
func (a *Agent) getToolDescriptions() string {
var descriptions string
for _, tool := range a.tools.List() {
descriptions += fmt.Sprintf("- %s: %s\n", tool.Name(), tool.Description())
}
return descriptions
}
func (a *Agent) getToolNames() []string {
var names []string
for _, tool := range a.tools.List() {
names = append(names, tool.Name())
}
return names
}
func (a *Agent) parseResponse(response string) (*ToolCall, bool) {
// Try to find JSON in the response
var jsonStr string
// Look for JSON object in the response
startIdx := -1
for i := 0; i < len(response); i++ {
if response[i] == '{' {
startIdx = i
break
}
}
if startIdx == -1 {
return nil, false
}
// Extract JSON
braceCount := 0
endIdx := -1
for i := startIdx; i < len(response); i++ {
if response[i] == '{' {
braceCount++
} else if response[i] == '}' {
braceCount--
if braceCount == 0 {
endIdx = i + 1
break
}
}
}
if endIdx == -1 {
return nil, false
}
jsonStr = response[startIdx:endIdx]
// Check if this is a final answer
var finalAnswerCheck map[string]interface{}
if err := json.Unmarshal([]byte(jsonStr), &finalAnswerCheck); err == nil {
if _, hasFinalAnswer := finalAnswerCheck["final_answer"]; hasFinalAnswer {
return nil, true
}
}
// Try to parse as tool call
var toolCall ToolCall
if err := json.Unmarshal([]byte(jsonStr), &toolCall); err != nil {
return nil, false
}
if toolCall.ToolName == "" || toolCall.Input == "" {
return nil, false
}
return &toolCall, false
}
Integrating with Ollama
To use a local LLM with Ollama, you’ll need to implement the LLMClient interface:
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// OllamaClient implements LLMClient using Ollama
type OllamaClient struct {
baseURL string
model string
client *http.Client
}
func NewOllamaClient(baseURL, model string) *OllamaClient {
return &OllamaClient{
baseURL: baseURL,
model: model,
client: &http.Client{},
}
}
type OllamaRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
}
type OllamaResponse struct {
Response string `json:"response"`
}
func (o *OllamaClient) GenerateResponse(ctx context.Context, prompt string) (string, error) {
req := OllamaRequest{
Model: o.model,
Prompt: prompt,
Stream: false,
}
body, err := json.Marshal(req)
if err != nil {
return "", err
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", o.baseURL+"/api/generate", bytes.NewBuffer(body))
if err != nil {
return "", err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := o.client.Do(httpReq)
if err != nil {
return "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var ollamaResp OllamaResponse
if err := json.Unmarshal(respBody, &ollamaResp); err != nil {
return "", err
}
return ollamaResp.Response, nil
}
A Complete Working Example
Let’s put it all together with a research agent:
package main
import (
"context"
"fmt"
"log"
"your-module/agent"
)
func main() {
ctx := context.Background()
// Set up tools
toolRegistry := agent.NewToolRegistry()
toolRegistry.Register(&agent.CalculatorTool{})
toolRegistry.Register(&agent.WebSearchTool{})
toolRegistry.Register(&agent.FileReaderTool{})
// Initialize LLM client (using local Ollama)
llmClient := agent.NewOllamaClient("http://localhost:11434", "mistral")
// Create agent
agentInstance := agent.NewAgent(toolRegistry, llmClient)
// Run the agent on a task
task := "Find out what 45 multiplied by 12 is, and then tell me if it's greater than 500"
log.Println("Starting agent with task:", task)
result, err := agentInstance.Run(ctx, task)
if err != nil {
log.Fatalf("Agent failed: %v", err)
}
fmt.Println("Final Result:")
fmt.Println(result)
}
Important Considerations
When building AI agents, there are several things you need to keep in mind:
Token Budgets
LLM APIs charge by tokens. Long conversations can get expensive quickly. You should:
- Keep conversation history trimmed or summarized
- Set reasonable maximum iteration limits
- Monitor token usage and log it
- Consider using cheaper models for simple tasks
Safety Guardrails
Before running tools based on agent decisions, consider:
- Validating tool inputs before execution
- Preventing access to sensitive files or operations
- Limiting the tools available to the agent
- Reviewing and logging all tool executions
- Setting maximum execution timeouts
Logging Agent Decisions
Always log what your agent is doing:
log.Printf("Agent iteration %d: Decided to use tool '%s'", iteration, toolName)
log.Printf("Tool input: %s", input)
log.Printf("Tool output: %s", output)
This helps with debugging when things go wrong and provides an audit trail.
Error Handling
Real-world agents will encounter failures. Handle them gracefully:
- Network timeouts from the LLM
- Tools that take too long or crash
- Invalid tool selections
- LLMs that don’t follow the expected format
Include retry logic and clear error messages for the agent to learn from.
Wrapping Up
You’ve now learned how to build AI agents in Go from scratch. You understand the ReAct pattern, how to create tool interfaces, implement an agent loop, and integrate with LLMs like Ollama.
The agent framework we built is simple but fully functional. In production, you’d want to add more sophisticated error handling, better prompt engineering, improved JSON parsing, and integration with real APIs.
The beautiful thing about agents is that they’re composable - once you have this basic framework working, you can swap out different LLMs, add new tools, and build increasingly sophisticated autonomous systems.
Start small with simple tools and tasks, understand how your specific LLM performs with agent tasks, and gradually add complexity. Happy agent building!
Continue Learning
Building AI Applications with LangChainGo
Learn how to build AI-powered applications in Go using the LangChainGo library with practical examples.
Building RAG Applications in Go
Learn how to build Retrieval Augmented Generation (RAG) applications in Go using LangChainGo and Ollama.
Calling Ollama from a Go Application
Learn how to interact with Ollama's REST API from Go to build AI-powered applications.
Getting Started with Ollama - Running LLMs Locally
In this tutorial, we'll look at how you can get Ollama set up on your machine and start running large language models locally.