Exploring My Own Agent From Scratch

Ever since agents made it into mainstream, I’ve been really interested in how they work. I always want my own personal Jarvis and now I feel like I have the chance to build one on my own. At it’s score, it’s mechanics: a loop that observes input, decides what to do, acts, and repeats. At scale, things get much more complex especially for user-facing products like ChatGPT or other AI coding assistants. What lies in between is a lot of lessons, techniques, and mechanics.

I’m trying to understand those mechanics.

I’m building an agent from scratch in Go for two reasons. First, I learn best by constructing the parts myself. Second, Go doesn’t have a mature, batteries-included agent framework like Python does (LangChain, AutoGPT, CrewAI, etc.), but it’s my favorite language to use. If I want a Go-native approach, I need to start from scratch basically. A lot of this is will be foreign for me but I have a few resources to help me out.


A Minimal Agent in Go

At its core, an agent is:

  1. Perceive: accept input (from a user or environment)
  2. Think: select an action (reply, call a tool, plan steps)
  3. Act: execute the action
  4. Repeat: feed the result back in

Everything else like long-term memory, planning, and multi-step orchestration is built on top this loop.

Below is a tiny but complete skeleton with three clear phases (Perceive → Think → Act), short-term memory, and graceful shutdown. Let’s walk through it step by step.

main.go

package main

import (
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	// Create and start the AI agent
	agent := NewAgent("CoreAgent")
	
	// Trap SIGINT/SIGTERM for graceful shutdown
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-sigs
		fmt.Println("\nSignal received, shutting down…")
		agent.Shutdown()
	}()

	// Start the agent's core loop
	agent.Run()

	fmt.Println("Agent has stopped.")
}

Explanation:

  • NewAgent(“CoreAgent”) creates a new agent instance with its own context and memory.
  • The signal.Notify block listens for Ctrl+C (SIGINT) or kill (SIGTERM) and calls agent.Shutdown(). This prevents the program from hanging or leaving goroutines alive.
  • agent.Run() starts the core loop (Perceive → Think → Act).
  • Once the loop exits, we print “Agent has stopped.”

This is the entry point: it sets up the agent, handles cleanup, and hands control over to the agent loop.


agent.go

package main

import (
	"bufio"
	"context"
	"fmt"
	"log"
	"os"
	"strings"
	"time"
)

// Agent represents the AI agent with its core components
type Agent struct {
	name    string
	memory  []string
	running bool
	ctx     context.Context
	cancel  context.CancelFunc
	reader  *bufio.Reader
}

// NewAgent creates a new AI agent instance
func NewAgent(name string) *Agent {
	ctx, cancel := context.WithCancel(context.Background())
	return &Agent{
		name:    name,
		memory:  make([]string, 0),
		running: false,
		ctx:     ctx,
		cancel:  cancel,
		reader:  bufio.NewReader(os.Stdin),
	}
}

Explanation:

  • The Agent struct is where we keep everything the agent needs:
    • name: the agent’s label, useful when printing prompts.
    • memory: a slice storing past interactions (short-term memory).
    • running: controls whether the loop keeps going.
    • ctx and cancel: allow us to stop the agent gracefully.
    • reader: wraps standard input so the agent can accept user input
  • NewAgent initializes everything:
    • Creates a background context with a cancel function for shutdown.
    • Starts with empty memory.
    • Prepares a buffered reader for console input.

Perceive: Gather Input

// Perceive gathers input from the environment (user input in this case)
func (a *Agent) Perceive() string {
	fmt.Printf("%s: What would you like me to help with? (type 'quit' to exit): ", a.name)
	input, err := a.reader.ReadString('\\n')
	if err != nil {
		log.Printf("Error reading input: %v", err)
		return ""
	}
	return strings.TrimSpace(input)
}

Explanation:

  • Prompts the user for input.
  • Reads a line from stdin.
  • Trims whitespace and returns it.

This is the “senses” of the agent. Right now, perception = keyboard input.


Think: Decide on Action

// Think processes the input and decides on an action
func (a *Agent) Think(input string) string {
	// Add input to memory
	a.memory = append(a.memory, fmt.Sprintf("User: %s", input))

	// Simple decision making based on input
	switch strings.ToLower(input) {
	case "quit", "exit", "stop":
		return "quit"
	case "hello", "hi":
		return "greet"
	case "help":
		return "help"
	case "memory":
		return "show_memory"
	default:
		return "respond"
	}
}

Explanation:

  • Stores the user’s input in memory.
  • Uses a basic switch to map inputs into actions.
  • Currently supports: quitting, greetings, help, showing memory, or a default “respond.”

This is the decision-making part. In the future, this is where support for LLM calls will begin.


Act: Carry Out the Action

// Act performs the decided action
func (a *Agent) Act(action string, input string) {
	var response string

	switch action {
	case "quit":
		fmt.Printf("%s: Goodbye! Shutting down...\\n", a.name)
		a.running = false
		return
	case "greet":
		response = "Hello! Nice to meet you!"
	case "help":
		response = "I can help with basic conversations. Try 'hello', 'memory' to see what I remember, or 'quit' to exit."
	case "show_memory":
		if len(a.memory) == 0 {
			response = "My memory is empty."
		} else {
			var b strings.Builder
			fmt.Fprintf(&b, "I remember %d interactions:\\n", len(a.memory))
			for i, mem := range a.memory {
				fmt.Fprintf(&b, "  %d. %s\\n", i+1, mem)
			}
			response = b.String()
		}
	default:
		response = fmt.Sprintf("I heard: '%s'. I'm still learning how to respond to that!", input)
	}

	if response != "" {
		fmt.Printf("%s: %s\\n", a.name, response)
		// Add response to memory
		a.memory = append(a.memory, fmt.Sprintf("Agent: %s", strings.TrimSpace(response)))
	}
}

Explanation:

  • Based on the action, the agent prints a response.
  • quit stops the loop.
  • show_memory prints out everything stored so far.
  • All responses are stored in memory so they can be recalled later.

This is the “actuator” part: how the agent interacts with the outside world.


Run: The Core Loop

// Run starts the main agent loop
func (a *Agent) Run() {
	a.running = true
	fmt.Printf("Starting %s AI Agent...\\n", a.name)
	fmt.Println("Core loop: Perceive -> Think -> Act")
	fmt.Println("----------------------------------------")

	for a.running {
		select {
		case <-a.ctx.Done():
			fmt.Printf("%s: Received shutdown signal\\n", a.name)
			a.running = false
			return
		default:
			// 1. Perceive
			input := a.Perceive()
			if input == "" {
				continue
			}

			// 2. Think
			action := a.Think(input)

			// 3. Act
			a.Act(action, input)

			// Prevent console overwhelm
			time.Sleep(100 * time.Millisecond)
		}
	}
}

Explanation:

  • Runs until a.running is false or the context is canceled.
  • Executes the core cycle: Perceive → Think → Act.
  • Includes a small sleep to avoid overwhelming the console.

This is the beating heart of the agent.


Shutdown: Clean Exit

// Shutdown gracefully stops the agent
func (a *Agent) Shutdown() {
	a.cancel()
}

Explanation:

  • Cancels the context, which signals the Run loop to stop.
  • Ensures the program can exit cleanly when asked to.

Current State of the Agent

Right now I got some foundation in place, a skeleton of an agent. It’s intentionally basic: the “thinking” is just a switch statement, the “tools” are hardcoded responses, and the “memory” is nothing more than a slice of strings in memory. This is enough to demonstrate the core loop of an agent, but I can clearly see the moving parts I’ll eventually need to improve.

What’s Next……

From this skeleton, I want to explore adding support for calling LLMs directly with providers like Ollama, OpenAI, or Anthropic, and letting the loop go beyond canned responses.

This will improve the quality of the responses a fair amount. Once that foundation is in place, I can start layering back in things like memory, tool use, and planning around the LLM core.