Moving the world

Within the past few weeks, I’ve made an extreme amount of progress in regard to the overall backend architecture and the overall frontend architecture.

Procedures

The central server defines procedures. Procedures are functions that define a procedure, a set of actions and steps that the simulation runs through to achieve an overarching goal. For example, there is a procedure for evolving the conlang called Evolve().

Evolve()

Evolve uses the functional architecture that I had described previously. Each iteration of the conlang is described as a Generation. A Generation consists of:

  1. Transcripts of the chats for each layer of the simulation.
  2. Logograms iterated upon.
  3. Specifications of each layer.
  4. Dictionary of updated, new, or removed words in the language.

As an aside, I find that the functional architecture is fitting for the way machines run and develop something. The entire iterative process is defined by recursive calls on a function stack, which is quite fitting for something that is a “machine”.

Procedures require a lot of synchronization and timing rectifications. Using Go’s go routines and channels was a godsend when it came to ensuring asynchronous operations could communicate between each other. I recommend this language to anyone who has future endevaours of async operations in a simulation domain.

LLM JSON

LLMs can now return JSON as their responses. This required me to rewrite most of the structure of the way LLMs interact with other internal services (like gRPC) and the way they are defined in code. The LLMs used in this project are:

  • ChatGPT 4.1 Mini
  • Claude 3.5 Haiku
  • Deepseek Chat v3
  • Ollama (Qwen)
  • Gemini 2.0 Flash and 2.5 Flash Preview

All these services use different interfaces to request JSON from their LLM. ChatGPT uses their proprietary OpenAI LLM request format and provides a library to work with it. Deepseek and Claude are capable of using this as well, however, Deepseek uses a json_object and may sometimes return a blank JSON object. Claude’s output is also inconsistent and wrong. Gemini uses its own request format and also provides their own library to work with it. Ollama is capable of using OpenAI’s format to accept requests, however they have their own library to work with their API (which I preferred).

From this, it is obvious that there are three points of wrangling API library data. OpenAI and Gemini’s libraries are incompatible in the way they express data sent to their APIs. Therefore, I decided to use another feature of Go, generics, to ensure maximum compatibility of the same data sent to each service. This took a lot of mental energy to implement because I had little experience with generics prior to this.

Generic implementations

First, JSON responses are defined as structs. Below is the struct definition of a dictionary entry update, which is used to decide which words in the dictionary of a generation should be updated, added, or removed.

package memory
type DictionaryEntry struct {
Word string `json:"word" jsonschema_description:"Dictionary entry word"`
Definition string `json:"definition" jsonschema_description:"Dictionary entry definition"`
Remove bool `json:"remove" jsonschema_description:"Remove word"`
}

The actual JSON request is a struct called DictionaryEntries:

type DictionaryEntries struct {
Entries []DictionaryEntry `json:"entries" jsonschema_description:"Dictionary entries"`
}

The corresponding JSON returned looks like this:

[
{
word: "",
definition: "",
remove: false,
},
...
]

Each LLM service exposes a generic function that can accept any type. The function then does a lookup in a registry (a map) using the type as a key. If that type is registered, the registry returns an object that contains object implementations of OpenAI and Gemini’s libraries for that specific type. The registry code is not known to either the server or agent implementations. Only the types are. This keeps library dependencies clearly separated and constrained to the llms package. This also lets all llms in the llms package use the registry as a global variable rather than instantiating an instance of it for their own use.

Generics are used here. A function lookupType[T] uses a generic declaration T to find the object implementations of type T in the registry. Using a generic here means we do not need to pass an object of type any in the function parameter to resolve the object, only its type.

registry.go
func lookupType[T any]() (*schema, error) {
var v T
t := reflect.TypeOf(v)
s, err := schemas.lookup(t)
if err != nil {
return nil, errors.Wrap(err, "failed to retrieve schema")
}
return s, nil
}

Type registration is done in llms/init.go. Init functions in Go are special side effects that run when a package is imported. Therefore, the registry is initialized and registers types at compile time rather than on-the-fly (this is a marginal saving in efficiency since lookups require reflect).

The request to the LLM then uses one of these object implementations to build a request. Here is the helper function used to select the type of a request:

func selectRequestType[T any](
ctx context.Context,
messages []memory.Message, c *client,
) (string, error) {
switch c.ModelConfig.Provider {
case llms.ProviderGoogleGemini_2_0_Flash:
fallthrough
case llms.ProviderGoogleGemini_2_5_Flash:
return llms.RequestTypedGoogleGemini[T](
ctx,
messages,
c.llmServices.gemini,
)
case llms.ProviderChatGPT:
return llms.RequestTypedChatGPT[T](
ctx,
messages,
c.llmServices.chatgpt,
)
case llms.ProviderOllama:
return llms.RequestTypedOllama[T](
ctx,
messages,
c.llmServices.ollama,
)
default:
c.logger.Warnf(
"JSON schema request for %s not supported, "+
"using default request method",
c.ModelConfig.Provider,
)
return c.llm.Request(ctx, messages)
}
}

When the agent is instructed to make a request that requires a specific type of JSON response, the typedRequest function is used like this:

go typedRequest[memory.DictionaryEntries](ctx, msg, c)

This launches a new go routine responsible for making the request. The implementation for receiving a response is irrelevant and hidden from the caller of typedRequest. The request’s response is sent to a channel. Another routine consumes the response from this channel to process it further.

Tunable realtime parameters

In redoing the LLM agent infrastructure, I also implemented a function signature to change the model’s behavior by manipulating its parameters. Therefore, model behavior, as determined by its parameters, can be modified and determined at request time.

To do this, the method buildRequestParams(rc *RequestConfig) params is defined on each LLM. rc is a pointer to a custom data type that contains model related parameters, like Temperature or Top-K. params is pseudo-code representing the library specific objects each LLM uses to make a request. Gemini and OpenAI libraries do not use the same type to make a request using their interface.

Tunable parameters enables more agent autonomy, letting them determine what they want to say and how they feel with stochastic precision.

Bridging the backend to the frontend

I have further dileneated the network components of the central server. These components now exist in a package called network. The package defines a ChatServer and a WebServer. The ChatServer handles agent-to-agent and server-to-agent communication, while the WebServer handles communication between output of generations and the web frontend, which is where data and simulation status is displayed.

Events

The frontend receives data from the backend using Server Side Events (SSE), which is a technology most browsers implement to receive realtime data updates from a server. It is unidirectional only from server to client, unlike WebSockets.

Whenever important data changes on the server, for example when a generational evolution is complete, the backend broadcasts the new data to all connected frontend clients.

When a new client joins, it receives the data that had been previously broadcasted earlier. Therefore, if a frontend client disconnects and reconnects, it will not have an empty user interface (there will be data to look at).

// inside Evolve()
// ...
err = s.ws.InitialData.RecentGenerations.Enqueue(newGeneration)
if err != nil {
s.errs <- errors.Wrapf(
err,
"failed to enqueue new generation to initial data %d", i,
)
}
s.ws.Broadcasters.Generation.Broadcast(newGeneration)
// ...

In the example above, the new Generation, newGeneration, is enqueued into a RecentGeneration. It is then broadcasted to connected clients.

Broadcasters

A broadcaster is used to broadcast information to SSE web clients. Each type of data that the frontend wants to display must have its very own broadcaster.

Broadcasters are implemented using generics (again!).

package network
type Broadcaster[T any] struct {
mu sync.Mutex
webClients map[*WebClient[T]]struct{}
logger *log.Logger
}

Each broadcaster keeps track of the web clients that are connected to its API route. When a broadcaster broadcasts an event, it iterates through its clients and sends the event to each of the clients. The sync.Mutex is for safe access when using go routines.

func (b *Broadcaster[T]) Broadcast(msg T) {
b.mu.Lock()
defer b.mu.Unlock()
for c := range b.webClients {
select {
case c.send <- msg:
default:
}
}
}

So the basic flow for data flow from client to server to web looks like this:

WebsiteServerAgent BAgent AWebsiteServerAgent BAgent Achat.Messagechat.Messagememory.Message

This diagram depicts the lifetime of a message. Agent A sends a message to the server destined for Agent B. The server receives the message and relays the message to Agent B. After that, the server broadcasts the message to the frontend website.

Routing with functions

I implemented a functional routing architecture to support ease of message routing. Whenever a message comes inbound to the server, the router handles the message’s distribution to services and clients.

The function BuildRouter generates a function that routes messages. It is a generic function and can therefore be used to route a message of any type (like if more protobuf types are added in the future).

package chat
func BuildRouter[T any](
ch chan T,
routes ...func(context.Context, T) error,
) func(errs chan<- error) {
return func(errs chan<- error) {
for msg := range ch {
ctx := context.Background()
for _, f := range routes {
err := f(ctx, msg)
if err != nil {
errs <- err
}
}
}
}
}

The function accepts a slice of functions routes that are iterated through, in the order they were assigned, when a message is received from ch. Every message that passes through the router receives the treatment of every route function.

What’s nice about this design is that each message has a routing context that is shared across each instance of each route function. Because of closures, local variables can be defined and shared amongst all routing functions without having to pass a pointer or function parameter around. Also, this allows “context busting” (I made that term up), which means that the context passed to each function can be canceled by any one of those functions, and when canceled, the cancellation will cascade sequentially to other routes without having to use a go routine.

Utilities

With the assistance of ChatGPT, I’ve also implemented a queue data structure (as you may have noticed from the code). I added some of my own modifications to this implementation though, implementing a ToSlice() method that copies the queue’s contents to a slice, implementing a QueueFromSlice[T any](s []T) method to go in the inverse direction, and also changing the behavior to be overwriting, such that when a new item is added to its fixed size, the first element is overwritten with the new item.

← back to blog posts