{"slug": "teaching-an-ai-to-know-itself-building-a-local-llm-agent-in-d", "title": "Teaching an AI to know itself: Building a local LLM agent in D", "summary": "Danny Arends, a long-time D language developer, has built DLLM, a minimal coding agent in D that runs directly on llama.cpp without Python or bindings, using D's ImportC feature to access the C API with zero FFI overhead. The project features a @Tool UDA registration system that automatically creates tool schemas from function signatures, and grammar-constrained sampling for structured output. Arends says the Python LLM ecosystem is enormous but layers of abstraction make it hard to debug, so he wanted to understand what was actually happening.", "body_md": "## Teaching an AI to Know Itself: Building a Local LLM Agent in D\n\nJun 7, 2026 • Danny Arends\n•\n[#Community](/search?q=Community),\n[#Guest Posts](/search?q=Guest+Posts),\n[#Project Highlights](/search?q=Project+Highlights),\n[#Tutorials](/search?q=Tutorials),\n[#Code](/search?q=Code),\n[#Machine Learning](/search?q=Machine+Learning)\n\nI’ve been writing D for a long time. [DaNode](https://github.com/DannyArends/DaNode), my self-contained web server, has been running in production for over 12 years. [DImGui](https://github.com/DannyArends/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`\n\n. So when I decided to build a local agentic large language model (LLM) ([DLLM](https://github.com/DannyArends/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.\n\n[DLLM](https://github.com/DannyArends/DLLM) is my latest D project: a minimal, clean coding agent built directly on llama.cpp. No Python, no bindings, no overhead.\n\nHere’s a walkthrough of the two parts I’m most happy with: the `@Tool`\n\nUDA registration system, and grammar-constrained sampling.\n\n#### Starting Point: ImportC\n\nBefore anything else, the foundation. D’s `ImportC`\n\nlets you include C headers and use the API directly, as native D code. DLLM has one file, `includes.c`\n\n, that pulls in the llama.cpp and mtmd headers. From there, `llama_decode`\n\n, `llama_model_load_from_file`\n\n, `llama_sampler_sample`\n\n, the whole llama.cpp API, is available in D with full type safety and zero FFI overhead.\n\nThis 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`\n\nis one of my favorite D features. I used to rely heavily on the Derelict & BindBC wrappers, and they were fantastic community contributions, but `ImportC`\n\nhas made them almost obsolete. No wrapper libraries, no binding maintenance, no surprises when the upstream C API updates.\n\n#### The Tool System: Start With a Single UDA\n\nAn 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:\n\n```\n@Tool(\"Count how many times substring appears in text.\")\nstring nOccurrences(string text, string substring) {\n  try {\n    return to!string(text.count(substring));\n  } catch (Exception e) { return(format(\"Error: %s\", e.msg)); }\n}\n```\n\nThe `@Tool(...)`\n\nattribute is the entire registration step. No schema file to maintain, no separate dispatch table. The `Tool`\n\nstruct itself is trivial:\n\n```\nstruct Tool {\n  string description;\n}\n```\n\nOne 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.\n\n#### Building Up: RegisterTools\n\nAt the top of each tool module, there’s one line:\n\n```\nmixin RegisterTools;\n```\n\nThis is a `mixin template`\n\nthat injects a `static this()`\n\nmodule constructor. When the program starts, that constructor runs and populates a global tool definition array (`ToolDef[]`\n\n) called `ALL_TOOLS`\n\n. Here’s how it works, step by step.\n\nFirst, it gets a reference to the current module using the `__MODULE__`\n\nstring mixin trick:\n\n```\nmixin(\"alias ThisModule = \" ~ __MODULE__ ~ \";\");\n```\n\nThen it loops over every symbol in that module using `__traits(allMembers, ...)`\n\nand `static foreach`\n\n:\n\n```\nstatic foreach(name; __traits(allMembers, ThisModule)) {{\n  mixin(\"alias member = \" ~ name ~ \";\");\n  static if (is(typeof(member) == function)) {\n    static if (hasUDA!(member, Tool)) {\n```\n\nFor each function that has a `@Tool`\n\nattribute, it extracts the description and the parameter names:\n\n```\nenum description = getUDAs!(member, Tool)[0].description;\nalias ParamNames = ParameterIdentifierTuple!member;\n```\n\n`ParameterIdentifierTuple`\n\nis a standard D trait that gives you the parameter names as a compile-time tuple: For `nOccurrences(string text, string substring)`\n\nthat’s `[\"text\", \"substring\"]`\n\n. Then it builds an executor closure that unpacks the JSON arguments and calls the function:\n\n```\nauto executor = (JSONValue args) {\n  string[] argValues;\n  static foreach(paramName; ParamNames) { \n    argValues ~= args[paramName].type == JSONType.string ? \n                 args[paramName].str : \n                 args[paramName].toString(); \n  }\n  // mixin generates: return member(argValues[0], argValues[1]);\n  mixin(callStr);\n};\nALL_TOOLS ~= ToolDef(name, description, parameters, executor);\n```\n\nSo after startup, `ALL_TOOLS`\n\n, 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.\n\n#### What Gets Generated: System Prompt and Grammar\n\nFrom `ALL_TOOLS`\n\n, two things are auto-magically generated. First, `toolsToJSON()`\n\ngenerates the JSON that goes into the system prompt, so the model knows what tools exist and what they can do:\n\n```\n[{\n  \"name\": \"nOccurrences\",\n  \"description\": \"Count how many times substring appears in text.\",\n  \"parameters\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"text\":      {\"type\": \"string\"},\n      \"substring\": {\"type\": \"string\"}\n    }\n  }\n}]\n```\n\nSecond, `buildJsonGrammar()`\n\ngenerates [a GBNF grammar](https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md) for constrained sampling. A GBNF grammar is a set of rules that define exactly what sequence of tokens (text) is valid. A simple example for a yes/no answer would look like:\n\n```\nroot ::= \"yes\" | \"no\"\n```\n\nThat’s it, the sampler can now only produce the word “yes” or “no”, nothing else. For DLLM’s tool calls, the grammar is more complex but the principle is identical. The `toolname`\n\nrule is generated dynamically from `ALL_TOOLS`\n\n, so only real tool names are valid. Everything else follows standard JSON structure rules.\n\nUnlike many Python-based agent frameworks, which handle tool calls with prompt engineering, output parsing, and prayer, grammar-constrained sampling gives an iron-clad guarantee that every tool call is structurally valid. The full GBNF grammar definition of valid JSON toolcalls is:\n\n``` js\nstring buildJsonGrammar() {\n  auto names = ALL_TOOLS.map!(t => \"\\\"\\\\\\\"\" ~ t.name ~ \"\\\\\\\"\\\"\").join(\" | \");\n  return(`\nroot ::= \"{\" ws \"\\\"name\\\"\" ws \":\" ws toolname ws \",\" ws \"\\\"arguments\\\"\" ws \":\" ws object ws \"}</tool_call>\"\ntoolname ::= ` ~ names ~ `\nobject ::= \"{\" ws (string ws \":\" ws value (ws \",\" ws string ws \":\" ws value)*)? ws \"}\"\narray ::= \"[\" ws (value (ws \",\" ws value)*)? ws \"]\"\nvalue ::= string | number | object | array | \"true\" | \"false\" | \"null\"\nstring ::= \"\\\"\" ([^\"\\\\] | \"\\\\\" ([\"\\\\/bfnrt] | \"u\" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F]))* \"\\\"\"\nnumber ::= \"-\"? ([0-9] | [1-9] [0-9]+) (\".\" [0-9]+)? ([eE] [-+]? [0-9]+)?\nws ::= [ \\t\\n\\r]*\n`);\n}\n```\n\nThe key part is the `toolname`\n\nrule, which is generated dynamically from the global `ALL_TOOLS`\n\ntool definition array. If you’ve registered `webSearch`\n\n, `nOccurrences`\n\n, and `countWords`\n\n, the rule becomes:\n\n```\ntoolname ::= \"\\\"webSearch\\\"\" | \"\\\"nOccurrences\\\"\" | \"\\\"countWords\\\"\"\n```\n\nThe model can only produce a `name`\n\nfield that contains a tool that actually exists. The grammar enforces it at the logit level. This solves the model hallucinating non-existing tools or producing malformed JSON; it’s structurally impossible.\n\n#### The Sampler Switch\n\nTwo samplers (the component responsible for selecting the next token) are set up at startup. The conversational sampler runs at temperature 0.7 during normal thinking and output generation. The JSON sampler runs at a lower temperature (0.3), and crucially, has the grammar constraint attached:\n\n```\nllama_sampler_chain_add(model.json, llama_sampler_init_temp(0.3f));\nllama_sampler_chain_add(model.json, llama_sampler_init_grammar(model.vocab, buildJsonGrammar().toStringz(), \"root\"));\nllama_sampler_chain_add(model.json, llama_sampler_init_dist(LLAMA_DEFAULT_SEED));\n```\n\nDuring generation, the code watches for `<tool_call>`\n\nand `</tool_call>`\n\ntags in the output stream. Switching samplers is a single line:\n\n```\nauto sampler = (agent.json && inToolCall) ? agent.json : agent.sampler;\nauto token = llama_sampler_sample(sampler, agent.ctx, -1);\n```\n\nThe moment a `<tool_call>`\n\ntag appears in the buffer, the grammar sampler takes over. The model *cannot* produce a malformed tool call while it’s active. After `</tool_call>`\n\ncloses, the grammar sampler is reset and the conversational sampler takes over again.\n\nNo parsing heuristics, no fallback regex. Malformed tool calls are structurally impossible.\n\n#### The Self-Knowledge Trick\n\nThe current version can read and reason about its own source code using just the Qwen 8B model. This isn’t magic, it’s [Retrieval-Augmented Generation (RAG)](https://en.wikipedia.org/wiki/Retrieval-augmented_generation). You can ask DLLM to index its own source code living in the *./src/* folder using the embedding model. Source code is chunked, chunks are tokenized, embedded using a dedicated CPU-resident Nomic embed model, and stored with cosine similarity scoring:\n\n``` js\nfloat cosineSimilarity(float[] a, float[] b) {\n  float denom = sqrt(a.map!(x=>x*x).sum) * sqrt(b.map!(x=>x*x).sum);\n  return denom == 0.0f ? 0.0f : dotProduct(a, b) / denom;\n}\n```\n\nThe index is binary-persisted between sessions using `rawWrite`\n\nand `rawRead`\n\n, with a magic number to catch stale files. When you ask a question, the top-k most relevant chunks are retrieved and injected into context.\n\nWhat makes it interesting is what’s being indexed. Because every tool is a plain D function with a `@Tool`\n\nattribute, the source files are already their own documentation. The model doesn’t have to reverse-engineer intent from implementation. The description is right there in the attribute, and the implementation is a few lines below it.\n\n*The practical result: you can ask “how does web search work?” and the agent retrieves the webSearch function, reads the @Tool description, and explains it accurately. With a small model. Locally.*\n\n#### What’s Included\n\nDLLM is more than just the tool system and grammar sampler. Here’s everything that’s included out of the box:\n\n- RAG with binary-persisted embeddings and cosine similarity ranking\n- Vision support via mtmd (load an image, ask about it)\n- Docker-sandboxed code execution (Python, JavaScript, Bash, R, D)\n- Web search via SearxNG, and web fetch\n- File I/O, date/time, encoding, audio playback tools\n- KV cache condensation via a dedicated summary model\n- Thinking budget enforcement via token limits\n- Memento system, where the agent writes notes to its future self between sessions\n- Full interactive and oneshot modes\n\n#### In closing\n\nD gave me `ImportC`\n\nfor zero-overhead access to llama.cpp, UDAs and `__traits`\n\nfor a tool system with one source of truth, and UFCS for code that reads the way I think. The entire tool registration and grammar generation system is about 150 lines.\n\nIf you’ve been looking for a project to try D on, local AI tooling is a good fit. The space is young, the performance characteristics reward D’s zero-overhead philosophy, and the metaprogramming needs of LLM agents map almost perfectly onto what D does best.\n\nDLLM is open source under GPLv3. The code is small enough to read in an afternoon. Find it at [github.com/DannyArends/DLLM](https://github.com/DannyArends/DLLM).", "url": "https://wpnews.pro/news/teaching-an-ai-to-know-itself-building-a-local-llm-agent-in-d", "canonical_source": "https://blog.dlang.org/2026/06/07/teaching-an-ai-to-know-itself-building-a-local-llm-agent-in-d/", "published_at": "2026-08-02 22:36:31+00:00", "updated_at": "2026-08-02 22:52:23.015459+00:00", "lang": "en", "topics": ["large-language-models", "ai-agents", "developer-tools", "machine-learning"], "entities": ["Danny Arends", "DLLM", "llama.cpp", "DaNode", "DImGui", "ImportC", "OpenSSL", "Vulkan"], "alternates": {"html": "https://wpnews.pro/news/teaching-an-ai-to-know-itself-building-a-local-llm-agent-in-d", "markdown": "https://wpnews.pro/news/teaching-an-ai-to-know-itself-building-a-local-llm-agent-in-d.md", "text": "https://wpnews.pro/news/teaching-an-ai-to-know-itself-building-a-local-llm-agent-in-d.txt", "jsonld": "https://wpnews.pro/news/teaching-an-ai-to-know-itself-building-a-local-llm-agent-in-d.jsonld"}}