Teaching an AI to know itself: Building a local LLM agent in D

teleforce1 pts0 comments

Teaching an AI to Know Itself: Building a Local LLM Agent in D | The D Blog

I’ve been writing D for a long time. DaNode, my self-contained web server, has been running in production for over 12 years. DImGui is a full SDL + Vulkan renderer that supports skeletal animations via the Open Asset Import Library, HDR lighting, and compute shaders, written entirely in D calling into external libraries via ImportC. So when I decided to build a local agentic large language model (LLM) (DLLM) from scratch, I’d sooner write it in Brainfuck than reach for Python. To be fair, the Python LLM ecosystem is enormous. However, by the time you have a working agent, you’re sitting on top of a framework, which wraps a library, which calls into C++ via ctypes, which dispatches to CUDA kernels. Python all the way down to the metal, with several layers of abstraction you didn’t write and can’t easily debug. I wanted to understand what was actually happening.

DLLM is my latest D project: a minimal, clean coding agent built directly on llama.cpp. No Python, no bindings, no overhead.

Here’s a walkthrough of the two parts I’m most happy with: the @Tool UDA registration system, and grammar-constrained sampling.

Starting Point: ImportC

Before anything else, the foundation. D’s ImportC lets you include C headers and use the API directly, as native D code. DLLM has one file, includes.c, that pulls in the llama.cpp and mtmd headers. From there, llama_decode, llama_model_load_from_file, llama_sampler_sample, the whole llama.cpp API, is available in D with full type safety and zero FFI overhead.

This is the same trick I used in DaNode to wrap OpenSSL, and an integral part of DImGui to call into Vulkan, SDL, the Open Asset Import Library, and shaderC. ImportC is one of my favorite D features. I used to rely heavily on the Derelict & BindBC wrappers, and they were fantastic community contributions, but ImportC has made them almost obsolete. No wrapper libraries, no binding maintenance, no surprises when the upstream C API updates.

The Tool System: Start With a Single UDA

An LLM agent is only useful if it can act. DLLM’s tools cover web search, file I/O, Docker-sandboxed code execution, image download, date and time, text encoding, and audio playback. To act, it needs tools that it can control, and in DLLM you can create a new tool that the agent can use like this:

@Tool("Count how many times substring appears in text.")<br>string nOccurrences(string text, string substring) {<br>try {<br>return to!string(text.count(substring));<br>} catch (Exception e) { return(format("Error: %s", e.msg)); }

The @Tool(...) attribute is the entire registration step. No schema file to maintain, no separate dispatch table. The Tool struct itself is trivial:

struct Tool {<br>string description;

One string. That’s the whole UDA definition. Everything else is derived from it and the function signature automatically. The description string is also used by the LLM agent to figure out what the tool is able to do.

Building Up: RegisterTools

At the top of each tool module, there’s one line:

mixin RegisterTools;

This is a mixin template that injects a static this() module constructor. When the program starts, that constructor runs and populates a global tool definition array (ToolDef[]) called ALL_TOOLS. Here’s how it works, step by step.

First, it gets a reference to the current module using the __MODULE__ string mixin trick:

mixin("alias ThisModule = " ~ __MODULE__ ~ ";");

Then it loops over every symbol in that module using __traits(allMembers, ...) and static foreach:

static foreach(name; __traits(allMembers, ThisModule)) {{<br>mixin("alias member = " ~ name ~ ";");<br>static if (is(typeof(member) == function)) {<br>static if (hasUDA!(member, Tool)) {

For each function that has a @Tool attribute, it extracts the description and the parameter names:

enum description = getUDAs!(member, Tool)[0].description;<br>alias ParamNames = ParameterIdentifierTuple!member;

ParameterIdentifierTuple is a standard D trait that gives you the parameter names as a compile-time tuple: For nOccurrences(string text, string substring) that’s ["text", "substring"]. Then it builds an executor closure that unpacks the JSON arguments and calls the function:

auto executor = (JSONValue args) {<br>string[] argValues;<br>static foreach(paramName; ParamNames) {<br>argValues ~= args[paramName].type == JSONType.string ?<br>args[paramName].str :<br>args[paramName].toString();<br>// mixin generates: return member(argValues[0], argValues[1]);<br>mixin(callStr);<br>};<br>ALL_TOOLS ~= ToolDef(name, description, parameters, executor);

So after startup, ALL_TOOLS, the global tool definition array contains everything needed to both describe each tool to the LLM agent and allow it to be called by name at runtime. The function signature is the single source of truth.

What Gets Generated: System Prompt and Grammar

From ALL_TOOLS, two things are auto-magically generated. First, toolsToJSON() generates the JSON that goes into the system...

tool string agent mixin text description

Related Articles