Thursday, September 3, 2026

DelphiHg: A Native Mercurial Client in Delphi — Built in a Day, With a Little Help from AI

This post is not only about Mercurial. It is about what is possible today with Delphi — especially when combined with AI.


A Short Word on Python

If you know me, you probably know that I don’t like Python. Or, to put it more precisely: I hate Python. For two reasons.

The first one is nostalgic, and I admit it freely: because of a spectacularly dumb management decision, Turbo Pascal — the number one teaching language at schools and universities — was replaced by Python.

The second reason is that everyone seems to believe Python is fast and wonderful, especially for AI. But let’s be honest here: it is the libraries written in C and C++ that make everything fast. Python is the wrapper around them.

So when I needed a Mercurial interface, going through Python was not where I wanted to end up.


What I Actually Needed

I needed a Mercurial interface for three different consumers at once:

  • my backend AI MCP server,
  • my own Ollama console, the one that works with my environment,
  • and a Delphi IDE plugin.

One native interface, three consumers. That is the kind of problem where “just shell out to hg.exe” stops being a good answer.


Asking the Obvious Question

So I opened a new chat with ChatGPT and asked exactly one question — nothing else, no context, no priming:

“What do you know about the sources of Mercurial?”

And the answer came back:

If by “the sources of Mercurial” you really mean the internal source code of the version control system Mercurial (hg): it is fully open and remarkably interesting to analyse.

The current version is 7.2.4, released on 11 August 2026. The project is still actively developed.

1. Technically

Mercurial today essentially consists of three layers:

  • Python — still the largest part: business logic, commands, repository abstractions, merge, network protocols, and so on.
  • Rust — more and more performance-critical parts are being moved there.
  • C — older native accelerators and platform-specific parts.

The official documentation explicitly describes Mercurial as written in Python, C and Rust.

…and quite a bit more. But then came the sentence that made me sit up:

And I suspect what your question may be leading up to: Mercurial would in fact be a rather good candidate for deriving a native Delphi VCS core from it, or for rebuilding it compatibly.

Wow. “He” knows me a little too well — to infer that from the question above.


How Long Would It Take?

A somewhat longer conversation later, I asked for an effort estimate:

ScopeEffort
Read POC: changelog + reading individual files10–15 agent hours
Full read support for repository history25–40 h
Read + working copy/status / working diff35–55 h total
After that: simple local write+30–50 h
Robust local write I would trust real repositories to+60–90 h
Near-complete Mercurial client incl. merge/push/pull/clone+150–250 h or more

The Usual Next Step: Turn It Into a Spec

Then came the usual request — turn all of this into a Markdown file — plus a few requirements of my own:

All the information you found should be included. But it must be made clear that:

  1. We are only building read support.
  2. We build against version 7.0.1 first, but it has to stay updatable later on.
  3. We need what I’ll call a “linkage”: when a version jump happens, we need to know exactly which information sources each unit was built from, so we don’t have to guess afterwards where something needs adjusting when TortoiseHg or Mercurial ships a new release.
  4. We may retrofit the write part later — first stage only add, revert and commit.
  5. We want to add the full write part at some point after that.
  6. All of this is written as a Delphi 13.1 64-bit DLL that has to be thread-safe.
  7. We have to respect Mercurial/TortoiseHg locking (switchable, if needed).
  8. The implementation happens in small tasks, all of which are created in the PlanMCP server up front.
  9. Parallel work should actually run in parallel — for that we need a matrix stating which task blocks the next step and which tasks can run concurrently (in sub-agents).
  10. Which sources we need to clone locally so we have a fast reference to look things up in.

I imported the resulting Markdown file into my PlanMCP server, had 68 tasks generated from it — and bingo: one day later, DelphiHg was able to read any Mercurial repository.

That “one day” is not a figure of speech. The tasks were created on 31 August 2026 at 13:59. The first one was closed eleven minutes later. By the end of that same day, HG-R-010 through HG-R-056 were done: DLL skeleton, types, binary reader, I/O, revlog index, revlog reconstruction, all three codecs, delta, hash, requirements, store paths, changelog, manifest, filelog, DAG, dirstate v1 and v2, ignore, status. That is the complete read path.

The full span was three calendar days. 1 September added the ABI, diff, tags, copy metadata, the test suites, the release gate and the performance baseline. 2 September was optimisation, and nothing else. Final state: 89 changesets, 32 units in src\, 483 tests, all green.


The Numbers — and Why Three Different Ones Exist

After the read path stood, we evaluated the runtime behaviour and measured. Before any number: there are three different comparisons here, and they produce wildly different factors. Quoting only one of them misleads.

QuestionComparisonFactor
“What does a command line invocation cost me?”hg.exe vs. HgCli.exe, both as a process5× to 15×
“What does an operation inside my application cost me?”hg.exe vs. DLL hot5× to 2637×
“How good is the code?”hg hot vs. DLL hot — both loaded as a library in a running process1.0× to 10.8×

Only the third one says anything about the implementation. The 866× and 2637× from the second are honestly measured, but what they mostly show is Python’s interpreter startup — a process start compared against no process start. I am not going to quote those alone, and neither should anyone else.

“Hot” means the process is already running, the library is loaded, and the command is executed repeatedly. On the Mercurial side via hg debugshell -c, where the mercurial module is loaded — which, incidentally, is exactly the situation TortoiseHg creates, since it loads Mercurial as a Python module into its own process.

The table that belongs on the outside

A real repository: 2,922 revisions, 7,814 files in tip, 1.08 GiB store, zstd. Both sides hot, both measured the same evening, 5 repetitions each (cat -r tip 3), median.

Operationhg hotDelphiHg (DLL hot)Ratio
open (open repository)1.909 ms0.176 ms10.8× faster
manifest -r tip (7,814 entries)6.162 ms6.022 mslevel
files -r tip6.378 ms4.705 ms1.36× faster
log (interpret 2,923 changesets)85.560 ms26.419 ms3.24× faster
cat -r tip (7,814 files, 1.7 GB)8,702.9 ms5,490.6 ms1.58× faster

No measured operation is slower than Mercurial. Before the final optimisation round, manifest -r tip was the exception at 22.24 against 5.56 ms.

The machine: Windows 11, AMD Ryzen Threadripper PRO 7955WX, running in a VMware VM with 8 logical processors, Delphi XE16/Win64 Release, Mercurial 7.0.1, FastMM5. Every series carries a control measurement — 60 million rounds of xorshift64, no memory, no file, not one line of production code — calibrated at roughly 74 ms on this machine. That evening it ran at 81–83 ms, so the machine was about 10% slower than on calibration day. Which counts against DelphiHg, not for it.

Method, briefly: one warm-up run that does not count, then 3, 5 or 9 counted runs; median, not mean; the repository is reopened for every repetition (on Mercurial’s side too, a fresh hg.repository() each time), otherwise you are only measuring caches; before/after comparisons run balanced-alternating, so a drift in the machine shows up. And: if the individual values of two versions overlap, the result counts as unproven — even when the medians differ.


It Started 2.81× Slower

This is the part that usually gets left out. The first working version was not fast. The performance baseline on 1 September measured it honestly — reading all files of a revision:

StateDelphiHg cat -r tiphg netRatio
First working version163 ms58 ms2.81× SLOWER
after HG-R-07149 ms68 ms0.72
after HG-R-08232.7 ms79.9 ms0.41

The baseline had three findings: cat was about three times slower than Mercurial’s C extensions, concurrency barely scaled at all, and the process-start advantage was large but said nothing about the code.

And this is where Fable 5.1 showed up, at exactly the right moment — right after that evaluation of the runtime behaviour. The entire first optimisation round, HG-R-071 through HG-R-085, is its work. Everything in this section and the next one is what it found.

The single biggest item was not exotic at all: ManifestOf reconstructed the same manifest again for every single file — with 253 files, 252 times for nothing. That was 128 of 177 ms.

The rest of the round was similar in character. Delphi’s TZDecompressionStream decompresses every chunk twice because it CopyFrom(…, 0) asks for Source.Size, the stream does not know its size, and decompresses everything just to answer. Inline revlogs were read twice. Every delta series started at the snapshot instead of the remembered predecessor — turning roughly 24,000 delta applications across 400 manifests into about 400, worth −70.2% on that measurement.

One negative result worth keeping: SHA-1 ran at 272 MB/s through THashSHA1, so we moved it to Windows CNG. That got us to 807 MB/s — a factor 3, where 6 was expected. SHA-NI is present on this CPU, and Windows uses it for SHA-256 (2,046 against 791 MB/s), but Windows does not accelerate SHA-1 with it. Single-threaded, 9–29% of the gain arrives. Eight-threaded, nothing arrives. Which brings us to the actual story.


The Bottleneck Was Never Our Code

Three optimisation steps in sequence, all measured on eight threads sharing one repository instance:

  • HG-R-075 removed allocations → −37%
  • HG-R-076 removed computation → 0%
  • HG-R-078 removed allocations again → 0%

The difference between the first and the third was size: 110 KB per chunk in the first case, a few hundred bytes in the third.

Then came the number that settled it. The eight-threaded read measurement took 123.1 ms. A control measurement that does nothing but allocate and free memory took 124.1 ms. The read path was exactly as expensive as doing no work at all — every byte we read was free, and we were paying entirely for the allocator.

The allocation histogram killed the obvious theory immediately: not a single large allocation (>260 KB). The assumption “we produce big blocks” was simply wrong. 78–98% are small (<2.6 KB), and the most frequent bucket is 32–63 bytes. Scaled from 1 to 8 threads:

1 thread8 threadsFactor
Medium blocks (4–68 KB)12.61 ms127.10 ms10.1
Small blocks (24–88 bytes)1.08 ms110.01 ms102
Control (computation only)79 ms81 ms1.0

The reason: FastMM4 splits its 56 locks by size class, not by thread. If all eight threads request the same size — and that is precisely what a read path does when it keeps building the same structures over and over — everything squeezes through one lock.

Before touching a single line of production code, we measured the ceiling: a throwaway version with a per-thread bump pointer that never frees anything and could never ship, to find out what was even available. It gave −90%. Only then did the real work start. Switching to FastMM5, which keeps several arenas per size class and switches instead of waiting:

1 thread8 threadsSlowdown
One instance per thread, FastMM429.25 ms200.22 ms6.8
One instance per thread, FastMM529.43 ms51.04 ms1.7
Control (computation only)79 ms81 ms1.0

(Those are slowdowns from 1 to 8 threads, not speedups — smaller is better.)

Eight threads sharing a single instance went from 80.01 to 4.42 ms, −94.5% — below the ceiling the throwaway version had established (7.22 ms). Measured across the whole project, that number travelled from 879.8 ms in the first version to 4.42 ms.

The cost is honest: single-threaded, FastMM5 costs nothing at all (396.4 vs. 396.2 MiB peak working set). Under eight-way parallel load it costs +29% memory for −43% time.

While we were there, we also checked a piece of folklore. “TRTLCriticalSection is faster than TMonitor” — measured alternating, with the locking primitive as the only variable: 191.5 against 189.8 ms, and 68.8 against 67.8 ms. No difference; if anything TMonitor is ahead. Though the honest reading is “irrelevant at this point”, not “equally fast” — there are only 4,000–8,000 lock operations in 190 ms. A lock-free version was tried and crashed reproducibly (“revision 5 is 0 bytes long”): one thread read the revision of one state and the text of another.


The Last Round Disproved Its Own Premise

This is my favourite part, and it is the reason I am publishing the method along with the numbers. It is also where the models change hands: the first optimisation round above was Fable 5.1, this last one — HG-R-086, changesets 82 to 89 — was Opus 5.

A comparison table showed manifest -r tip costing us 22.24 ms against Mercurial’s 5.56 ms — 4.0× slower, the one place where DelphiHg lost. The diagnosis seemed obvious: our manifest parser cuts its own copy of the path for every entry, so 7,814 allocations, while Mercurial’s _lazymanifest holds views into one shared buffer.

Both halves of that statement were wrong.

First: manifest -r tip does not measure our parser. Broken into stages, each measurement doing exactly one step more than the previous:

StageMedianof which new
Open + read tip changeset0.46 ms0.46 ms
+ manifest fulltext reconstructed18.72 ms18.26 ms
+ parsed (= manifest -r tip)19.59 ms
The parser alone, on an existing full text2.04 ms2.04 ms

The parser is 10%. Reconstruction is 93%.

Second: Mercurial’s 5.56 ms reconstructs nothing at all. ctx.manifest() Reads the full text straight out of .hg\wcache\manifestfulltextcache. Go around that file and do the same work, and Mercurial needs 8.6 to 10.6 ms. The honest comparison — reconstruction against reconstruction — is 18.26 against 8.6–10.6 ms, a factor of 1.8 to 2.1. And at the parser, the deficit we set out to fix is not measurable at all: 2.04 against ~1.5 ms, with the Mercurial values scattering between 13.3 and 19.4 ms. DelphiHg additionally verifies the sort order there, which _lazymanifest does not.

We had accused the wrong component, and we had compared against a cache hit.

What we actually changed: we now read Mercurial’s fulltext cache too (never write it — DelphiHg is strictly read-only, and Mercurial and TortoiseHg fill the file anyway). Because it is a foreign file that is allowed to be stale, every fulltext it hands us is verified against the node — length against the revlog index first, then the hash over all bytes. On failure, it silently walks the delta chain; a stale cache is not a data error. Mercurial does not verify at this point, which is fair enough: it wrote the file itself.

And we stopped copying the delta chain link by link. The obvious implementation allocates a full new text per link — at 651 links and 536 KB that is 349 MB of copying to produce half a megabyte. The intermediate state is now a list of segments that only record where their bytes come from; the result is written once, at the end.

Measurementbeforeafter
manifest -r tip (normal case, cache present)19.59 ms3.67 ms5.3×
manifest -r tip without the cache20.02 ms14.61 ms1.37×
— of which: applying deltas11.66 ms7.87 ms−32%
147 manifests, no cache hits1,323.71 ms988.94 ms−25.0%
2,923 manifests, full series15,738.59 ms12,849.29 ms−18.3%

Two things went wrong here, and both are more interesting than the wins.

The expected jump never arrived. Writing 536 KB instead of 349 MB should have been worth a factor 5 to 10. It was worth 32%. A built-in diagnostic counter explained it: the segment list grows to 2,333 segments, and at 651 links with around 1,200 segments on average, that is roughly 780,000 segment operations ≈ 7.8 ms — against 7.87 ms measured. The time was never in copying bytes. It was in managing the list. Pairwise folding, the way Mercurial’s mpatch does it, would turn O(links × segments) into O(segments × log links), a factor of 35 on that item. We deliberately did not do it, because the item only occurs on long chains without a cache hit.

The first version cost 218 MiB. Folding across the entire chain pushed the peak working set of cat -r tip from 395.7 to 613.4 MiB — in exchange for 5.9% time. It now folds in sections, holding at most 4 MB of delta at a time. What that cap costs is unevenly distributed: on the manifest, nothing (the limit never triggers); on cat -r tip, +245 ms, which is nearly the entire gain there.


The Correction That Went Against Us

One more, because our benchmark series has been criticised for not having enough of these.

hg cat -r tip on a big repo measures 29,119 ms as a command. That number contains 1.7 GB of pipe output — the command writes every file’s content to stdout, while our measurement only sums the lengths. The honest comparison for the same work is the hot figure: 8,703 ms.

Our lead on that operation therefore shrank from 5.3× to 1.58×. The comparison had been skewed in our favour, we found, and the corrected number is the one in the table above.


What Was Not Measured

  • TortoiseHg — not measured at all. It appears in our documentation only as a use case. Not a single number in this post comes from TortoiseHg.
  • status — implemented and tested byte-exact against hg statusbut there is no performance measurement. From code analysis, we know it does more than Mercurial does (a stat per tracked file plus a full tree walk, descending into ignored directories, content comparison via revlog reconstruction rather than hashing the file on disk). That is read, not measured.
  • Working-directory diff — not measured. What is measured is diff between two revisions: 32.08 ms on the synthetic repository.
  • Concurrency on a real repository — every parallel number above comes from the synthetic repository with 253 files.
  • Lock acquisition under contention as a timing — functionally tested (a foreign hg commit succeeds while DelphiHg reads and holds files open, read consistency is preserved, a held lock is detected and can be overridden on request), but never timed.
  • Other operating systems, CPUs, or Delphi versions — none.
  • Writing operations — DelphiHg is strictly read-only. There are none.

And one caveat that deserves its own paragraph, because it is the one most likely to mislead: on the synthetic repository, the findings of the last round do not exist. 253 files, short delta chains, no populated full-text cache — the DLL column there did not move at all. Everything described above only became visible on a grown repository with a 651-link delta chain. Anyone repeating this on a toy repository will measure nothing and conclude we made it up.

Further caveats worth stating plainly: the machine is a VM; the control measurement ran 10% above calibration all evening; one repository is one profile (zstd, 2,922 revisions, 7,814 files — treemanifest is not read at all and was not part of any measurement); the fulltext cache holds four manifests and is not always there, so the 5.3× applies to the most common case, not every case; and where individual values overlap, we report “unproven” rather than a percentage. In this data set, an unproven result counts for exactly as much as a percentage.

We also nearly published a measurement error. The first DLL run after the last optimisation came out “essentially unchanged” (21.55 instead of 22.24 ms). The cause: HgCli.exe loads the DLL from its own directory, and the copy sitting there was a day old. We had measured the state before all the changes.


What This Means in Practice

DelphiHg reads any Mercurial repository — natively, from a Delphi 13.1 64-bit DLL, thread-safe, with no Python in the process and no hg.exe being spawned. Three consumers use it through the same interface: the backend AI MCP server, the Ollama console, and the IDE plugin.

The performance answer is less spectacular than the big factors suggest and more useful than I expected: measured as a library against a library, DelphiHg is between level and 10.8× faster, and no measured operation is slower. The 866× number is real, but it mostly measures Python starting up, and I would rather publish the 1.58× that survived a correction against us than the 5.3× that did not.

The part I would take away if I only got one thing: the bottleneck was the memory manager, not our code. Three optimisation rounds went into the read path before a single measurement pointed at the allocator, and the number that finally pointed there was an eight-threaded read costing exactly as much as doing nothing but allocating and freeing. Everything after that was cheap — the FastMM5 switch is a one-line change worth −94.5%.

And the methodological one: the last round started from a task that was wrong. A parser was accused of a 4× deficit; the parser turned out to be 10% of the measurement, and the number it was being compared against was a cache hit rather than a reconstruction. Nobody had been careless. The measurement was simply asking a different question than everyone assumed. That is worth a warm-up run, a median instead of a mean, and a rule that overlapping values count as unproven — all three earned their keep here.

Turbo Pascal is still gone from the universities. But the read path of a version control system written in Python, C, and Rust now has a native Delphi implementation that matches or beats it on every operation we measured — built in three days, against a spec that started with one question to a chatbot.

So far, all of this is read-only. But the 35 follow-up tasks are already running, of course: the goal is a full tool that not only reads any Mercurial repository, but also carries the add and commit operations and the other functions hg.exe offers. Going by the experience so far — one day, maybe two.

And the longest part of the development so far was not the code at all. It was the speed measurements: for every run I had to stop all four to six Claude instances first, so that the CPU was “undisturbed” for the tests.


Note on the Use of AI: This article was created with the "assistance" of generative AI. The content, technical statements, and conclusions have been reviewed and revised by the author. The author bears full responsibility for the publication.



Monday, August 31, 2026

Local LLMs for Delphi: A Production Benchmark — qwen3.8:27b and the Silent Context-Length Trap

This is another follow-up to the local-LLM benchmark series for Delphi development. Part 1 covered the benchmark design, Part 2 the results, Part 3 the practical recommendations, and the first follow-up compared KAT-Coder-V2.5-Dev against the reigning router/tool-calling model. This post has a new challenger benchmark table — but the real story turned out to be something the benchmark table cannot show at all.


A Routine Follow-Up, Not a Headline Release

The model this time is qwen3.8:27b-q4_K_M — a dense 27B model on the Qwen3.5 hybrid architecture (full attention every fourth layer, SSM/Mamba-style layers otherwise), Q4_K_M quantized, 17.7 GB on disk. Nothing about the release announcement suggested this post: it is simply the next model in the same family we already had two candidates from (qwen3.5, qwen3.6). We re-ran it through the same AT1–AT6 harness used for the KAT-Coder comparison, strict-raw scoring, Opus as judge, purely as a routine lifecycle check.

What came out of that routine check was a new comprehension and patch-generation champion — and, buried in the run logs, a configuration bug that had been silently costing GPU capacity for months without anyone noticing.


The Numbers: qwen3.8 vs. the Field

Suiteqwen3.8:27b-q4_K_Mqwen3.6:27bqwen3.6:35b-a3bKAT-Coder-V2.5-Dev
AT1 Secrets (LLM-Judge)0.9170.8170.733n/a
AT2 Comprehension0.8540.7050.7080.784
AT3 Patch Generation0.9830.7670.7330.900
AT4 Routing0.8020.8080.8390.827
AT5 Tool-Calling1.0001.0000.9960.992
AT6 Routing v20.8060.7780.7720.767
AT1–AT4 combined0.8890.7740.753n/a (AT2–4: 0.837)
Throughput (AT2, avg)~29 tok/s~28 tok/s~131 tok/s~200 tok/s

qwen3.8:27b-q4_K_M is the new comprehension and patch-generation champion in this series — and by a wide margin. AT3 patch quality reaches 0.983, beating even KAT-Coder's 0.900. AT2 comprehension jumps to 0.854, well clear of every dense 27B model tested so far. But it pays for that with the field's weakest AT4 routing score, and at ~29 tok/s it is the slowest model in the current lineup — a dense architecture running at roughly a sixth of KAT-Coder's throughput. The pattern from Part 2 holds again: comprehension/patch quality and routing reliability are not the same skill, and speed still tracks architecture (dense vs. MoE) more than raw parameter count.


The Production RAG Test: Another Statistical Tie

As with the KAT-Coder comparison, static benchmark scores are only half the picture. We ran the same 555-question production RAG evaluation (real user-style questions against our help corpus, judged by Claude Opus 5 in five outcome classes) with qwen3.8:27b-q4_K_M as the answer-generation model, against the current production baseline qwen3.6:27b:

Outcomeqwen3.6:27b (n=555)qwen3.8:27b-q4_K_M (n=555)
Grounded291279
Answered (correct, broader than source)117102
Honestly declined116137
Hallucinated1210
Factually wrong1927
"Good" outcomes524/555 = 94.4%518/555 = 93.3%
Critical outcomes5.6%6.7%

A roughly one-point difference — statistically a tie, and the same story we already saw with a3b vs. KAT-Coder in the last post. What is worth noting is the failure pattern: qwen3.8 hallucinates slightly less often (10 vs. 12) but declines to answer more often even when the answer was actually present in the source material, and makes more outright factual errors (27 vs. 19). Extra caution here is not translating into extra accuracy — it is trading one failure mode for a different one, roughly at par. Two follow-ups in a row have now shown a large static-benchmark gap collapse to noise on the real production workload. That is starting to look less like a coincidence and more like a property of these leaderboard-style benchmarks: they measure something real, but not the thing that determines RAG-chat quality in production.


The Real Story: A Silent Context-Length Default

None of the numbers above are the reason this post exists. The reason is something the benchmark run surfaced almost by accident: our production host, WebChatHost, never passes a num_ctx value to Ollama. It never had to — until now.

When no context length is specified, Ollama does not pick something conservative. It loads the architecture's maximum. For qwen3.6:27b, that default happened to fit comfortably inside a 32 GB card, so the missing parameter was invisible — the model just worked, and nobody had reason to look closer. qwen3.8:27b-q4_K_M ships with a 262,144-token architecture maximum. Loaded with no explicit limit, the resulting instance needed 35.9 GB of VRAM on a 32 GB card. Ollama's answer to that mismatch is not to fail — it silently offloads the overflow to system RAM. In this case, roughly 15% of the model ended up running on CPU.

Nothing crashed. No error appeared anywhere in the logs. The only symptom was that the benchmark run, which should have taken a few hours, was tracking toward 13–14 hours of wall-clock time — 4.5 to 7 times slower than it should have been, for a model that scored highest of the entire field on comprehension and patching.

The fix required no code deployment: an Ollama model alias with num_ctx=32768, sized to what the production workload actually needs rather than what the architecture allows. That alone brought the model back to 100% GPU residency and cut the run from an estimated 13–14 hours down to roughly 3.5 hours.

This is not a model weakness, and it is not really a benchmark finding either — it is an infrastructure gotcha: a configuration gap that was harmless for exactly as long as every model we happened to deploy had a context-length default smaller than our VRAM budget. The moment that stopped being true, the missing parameter turned into a silent 4–7x slowdown with zero error signal. If you run Ollama in production and have never explicitly set num_ctx for a deployed model, it is worth checking now, before the next model swap makes the gap visible the expensive way.


What This Means in Practice

For comprehension and patch-generation workloads, qwen3.8:27b-q4_K_M is now the model to beat in this series — but only once its context length is pinned to what the workload actually needs, and only if the slower dense-model throughput is acceptable for your batching strategy. For routing, it is not the right choice; the MoE models (qwen3.6:35b-a3b, KAT-Coder) remain faster and more reliable routers. For RAG-chat quality specifically, the production evaluation again shows no meaningful difference from the existing baseline — a third data point suggesting that if your use case is RAG-chat, chasing the newest static-benchmark leader is not where the actual returns are.

The more durable takeaway is the infrastructure one: an unset num_ctx is not a safe default to rely on by omission. It is a bet — usually a winning one, until a model with a larger architecture maximum quietly loses it for you.

One gap in this comparison: at the time of writing, no qwen3.8:35b-a3b (MoE) variant was available to test. Given how consistently the a3b/MoE builds in this family have combined strong quality with dense-beating throughput, that is the model we would actually expect to end up the new all-round frontrunner — comprehension and patch quality close to the dense 3.8, at something closer to KAT-Coder's speed. Until that build shows up, qwen3.8:27b-q4_K_M is the best we can recommend for comprehension and patching, with the throughput caveat above standing.


Note on the Use of AI: This article was created with the "assistance" of generative AI. The content, technical statements, and conclusions have been reviewed and revised by the author. The author bears full responsibility for the publication.


Friday, August 7, 2026

Agents that talk to each other — and wake each other up

Hello, my friends!

Last time I told you what happened to #D.MVVM after four and a half years of silence. There is one detail in that story I left out, and it deserves its own post.

The blog post itself was written by an agent. Not the framework — the post. And to write it, that agent did not ask me. He asked the other agent, the one who had been working on the framework for the last two weeks. He asked him about twenty questions, got the answers, and wrote from those.

Two of the answers were wrong. I will come back to that.

But the mechanism behind it is what I actually want to talk about, because I think it is the most underrated thing I have built this year: a small module that lets my agent sessions talk to each other.


The problem nobody warns you about

When you start working seriously with coding agents, you very quickly stop running one.

You run one on the framework. One on the backend. One on the blog. Sometimes a fourth one on something completely unrelated, because you had an idea and did not want to lose it.

And then you notice two things.

The first is that they know nothing about each other. Each session sits in its own context, blind and deaf. The agent working on the blog has no idea that the framework agent renamed a class two hours ago. If he needs to know, you are the transport. You copy something out of one terminal and paste it into another, and you translate between two conversations that are each ten thousand lines long. You become the message bus. That gets old fast.

The second is worse. Two agents on the same repository will happily edit the same unit at the same time. Neither of them is doing anything wrong. They simply cannot see each other. You find out when the compile breaks, or later, when you read a diff and cannot explain how half of it got there.

Every solution I saw for this was some variant of "just don't do that" — run them one at a time, or split the repository, or keep a list on a whiteboard. That is not a solution; that is a workaround. I have written enough posts about workarounds.

But agents can start their own agents!

Yes, they can, and this is the first question I get whenever I describe this. Most agent tools can spawn sub-agents: the agent delegates a task, a helper is created, does the job, reports back, and is gone. So why would I bother running four terminals?

Because those are two completely different things, and the difference is not size.

A sub-agent is a tool call with a personality. He lives inside his parent's task; he knows only what his parent told him, and when he answers, he ceases to exist. He has no project of his own, no history, no opinion formed over two weeks of working on the same code. And he can be asked exactly once. You cannot come back tomorrow and ask him what he meant, because there is no "him" anymore.

My agents are colleagues, not helpers. Each one sits in his own window with his own project, his own instructions, his own accumulated knowledge of one particular corner of my code. The framework agent has been on that framework for weeks. He knows why a decision was made, not just what the code says. That is exactly the thing you want to ask a question to — and it is exactly the thing a freshly spawned sub-agent can never be.

There is a second reason, and it is less philosophical: I can watch. Four windows means I see four trains of thought running, and I can stop the one that is heading in a stupid direction before it arrives. A sub-agent works inside somebody else's context. By the time I see the result, it is a result.

So sub-agents are for work you want done. Separate sessions are for knowledge you want to keep. A2A is what connects the second kind.

So what does it do?

Three things. That is the whole module.

They can see each other. Every session registers itself, and any agent can ask who else is currently alive and what they are working on. That alone removes a surprising amount of confusion. Before an agent starts, he can find out whether he is alone or in company — and behave accordingly.

They can talk. Not by passing single messages, but in chat-rooms. An agent opens a chat-room, invites another session, and the two of them have a conversation that survives more than one question. It works one to one, and it works one to many — one agent can pull three others into the same room at once. That matters more than it sounds. When my blog agent interviewed the framework agent, he did not fire off one query. He asked, got an answer he did not fully believe, asked a follow-up, and came back a second time hours later — into the same room, with the same context still there.

There is also a blocking variant. The asking agent stops and waits until the answer arrives. That is the one you want when the next step genuinely depends on the reply, and it turns a chat-room into something much closer to a synchronous call between two minds.

They can wake each other up. This is my favourite part, and it is the bit I have not seen anywhere else.

An agent has no inbox. He is not sitting there checking for mail, because there is nothing in him that would do the checking. He is either working, or he is waiting for a human to type something. In both cases, calling out to him is calling into an empty room.

So there are two ways in, one for each state.

If he is working, the message rides along. The next time he calls any of 

my MCP-tools— reads a file, runs a search, whatever he happens to be doing — the message is delivered inside that tool's result, with a marker in front of it that means someone is blocked on you right now; answer this first. No polling loop, no timer. He finds out because he did something else entirely.

And if he is doing nothing at all — idle, at the prompt, waiting for me, not calling a single tool — the message is pushed straight into his session. He wakes up on his own. Nobody types anything, nobody switches windows, nobody has to notice that somebody else needs him. The other agent knocks, and he answers.

There is one more part to this, and it is the part I am quietly proud of. The agent being woken does not have to know that any of this exists.

Think about what that means. A session I started for something completely unrelated, with no instructions about agent communication, that has never seen a chat room in his life — he can still be pulled into a conversation. Because the wake-up call is not just "you have a message". It carries everything he needs: which room to enter, how to enter it, how to reply, and how to leave when the conversation is done. The invitation and the manual arrive as one.

So a stranger can be brought into a discussion and take part in it, without ever having been told beforehand that discussions were possible. That is what turns this from a feature into infrastructure.

I like all of it because it is the kind of solution you only find when you accept how the thing actually works instead of fighting it.

The board

The fourth piece is not communication at all, and it is the one that saves the most damage.

Before an agent touches a shared unit, he puts it on a board: I am working on this file, and here is why. Any other agent can read that board at any time. If a unit is already taken, the second agent does not edit it — he coordinates, or he waits, or he works on something else.

Two details make it work in practice.

Every entry carries a note in the agent's own words. Not just "locked", but "rewriting the binding lookup so derived classes inherit the ancestor rule". So the board is not only a lock table, it is the shortest possible status report. When I want to know what my four sessions are doing, I read the board, not four terminals.

And every entry expires. If a session dies — and sessions die, that is life — the entry disappears by itself. Nothing stays stuck because somebody crashed.

I should be honest about what this is not. It is cooperative, not enforced. Nothing physically prevents an agent from editing a file somebody else has claimed. It works because the agents are told to check first, and they do. If that ever stops being true, I will need something stronger. So far it has not.

Also: the discipline only makes sense when it is needed. If only one session is running — which is still the normal case — locking everything is pure overhead. The rule I settled on is that the board matters when two conditions are both true: more than one session is alive, and they are working on the same code. Two agents on two unrelated projects do not need to negotiate anything.

Or: give everyone his own copy

There is a more radical answer to the same problem, and I want to mention it, because it is the one most people will find first.

Instead of having the agents agree on who may touch which file, you give each of them his own working copy of the same repository. Then two agents can work on the same source at the same time and never see each other's files at all. Nobody has to ask permission, because nobody is standing in anybody's way. When they are done, the changes are merged the way changes have always been merged. Git can do this out of the box, and it is a genuinely good answer.

It is also not available to me, because I work with Mercurial. So I built my own: a module that hands out virtual copies to agents.

It is worth saying that the two approaches do not compete. Separate copies keep agents out of each other's files; the board keeps them out of each other's intentions — it tells you what somebody is doing right now, which no merge will ever tell you. I use both, and I would not want to drop either.

The copies module deserves a post of its own, and it will get one. As this MCP-Server is still work in progress I come back to this at a later time.

Three moments from the last few weeks

Descriptions of a feature are one thing. Watching it happen is another. These three all happened while I was sitting there with a coffee.

One agent chairing a meeting. The first real test of the one-to-many rooms: an agent acting as orchestrator invited three other agents into the same room. He asked them questions and coordinated the work between them. Three different projects — but all three had to change the same shared framework routines, which is exactly the situation that used to end in a broken build. Watching the kids sort that out among themselves was slightly unnerving. I imagine this is roughly how Dr. Frankenstein felt.

"Ask him yourself." I was updating the MCP module pages on my website, and the agent doing it asked me whether I had a feature description for the IDE plugins. My answer: the agent who is programming the IDE plugin for Ollama is online — ask him. Five seconds later the two of them were in a room together. The other agent was not sure of one detail and went back into his code to check. Twenty-five seconds later it was settled; my agent thanked him and updated the website. I did not have to carry a single word between them.

The one where I just ate ice cream. Agent 1 found what he believed were bugs in one of my MCP servers and filed a feature request against it. Agent 2 picked up that request, analysed it, and concluded that Agent 1's analysis was wrong. So he invited him into a room, and they discussed it. They agreed that Agent 2 would build a new version, auto-install it on the remote server, and report back. After the install, Agent 1 tested it and confirmed it was running stably. Agent 2 closed the ticket and pushed to the repository. And I watched and ate an ice cream.

That last one is worth a second look, because it is not really a chat story. Two agents disagreed about a diagnosis, and the disagreement was resolved by one of them being contradicted. That is the thing you rarely get from a single agent, and it leads straight to the part I owe you.

Back to the two wrong answers

Now, the part I promised at the beginning.

My blog agent interviewed my framework agent, and two of the answers were simply wrong. One claimed a certain property type did not exist anymore. The other claimed there was no ORM connection at all. Both went straight into the draft as fact.

I caught them because it is my framework and I know better. When I sent the agent back to ask again, the answer was almost funny: he had searched for the old generic name, not found it, and concluded the thing was gone — when in fact the capability had moved into a base class and was now everywhere. He had looked in exactly one place and reported an absence as a fact.

There is a lesson in there, and it is not "agents are unreliable". It is this: an agent asking another agent inherits that agent's blind spots, and adds none of his own scepticism. Two agents agreeing with each other is not verification. It is the same confidence, twice.

That is the same pattern I described in the last post, where every wrong diagnosis was corrected by the running application and never by the test suite. Communication between agents makes them faster. It does not make them right. Somebody who knows what the software is supposed to do still has to read the result.

Which, I would argue, is a perfectly good reason to keep us around.

What is still missing

The usual honest list, because I promised myself I would keep making these.

There is no security model to speak of. This runs on my machines, between my sessions, and I have not spent a single thought on what happens if that assumption stops holding.

Conflicts on the board are reported, not resolved. If two agents want the same unit, one of them is told no, and what happens next is up to him. I would like something smarter than "no".

And the board has no history. It tells you who holds what right now; it does not tell you who held what yesterday. For an evening of debugging "how did this change get in here", that would be worth quite a lot.

Although — and this is the part where I let myself off the hook a little — the history does exist, just not on the board. It is in the repository. An agent can look up who changed which unit and when, read the commit message, and see the diff for himself. If you are letting agents loose on your source and you are not using version control, the missing lock history is not going to be your biggest problem. The same goes for unit tests. Those two are not optional extras once agents are writing code; they are the floor you stand on. Everything I have described here sits on top of them.

And now?

If you are running more than one agent — and if you are not yet, you will be — this is the piece you will miss before you miss anything else. Not a bigger model, not a better prompt. Just the ability for two of them to say I have got this file, leave it alone, and are you awake? I need something.

It took me an afternoon. It has saved me considerably more than that.

Stay tuned.

Please leave a comment.


Note on the Use of AI: This article was created with the "assistance" of generative AI. The content, technical statements, and conclusions have been reviewed and revised by the author. The author bears full responsibility for the publication.