Text matching
Several places in your agent must ask a fuzzy question: “did the model mean read_file when it asked for read_fille?”
Or: “which of my memory entries have anything to do with this query?” There is no language model available to answer.
Your engine is made of the model’s plumbing, and these questions must be answered instantly, offline, on every call.
The answer is a family of character-and-word tricks: similarity measures that understand nothing about meaning but are fast, dependency-free, and wrong in known, bounded ways.
loopctl uses three of them for you, plus Jaccard, which is covered with convergence detection.
Each one is explained below with its real formula and a worked example you can check by hand.
1. Longest common subsequence
Section titled “1. Longest common subsequence”When your dispatched call misses the registry, your UnknownToolMiddleware appends a suggestion: Did you mean 'read_file'?
The similarity behind it is the longest common subsequence (LCS): the longest sequence of characters that appears in both your strings, in order, with gaps allowed:
"read_fille" r e a d _ f i l l e"read_file" r e a d _ f i l eLCS: r e a d _ f i l e → length 9 of a max length of 10“Subsequence, not substring” is the point: the extra l in your typo does not break the match, because matching may skip it.
Here is your similarity score:
similarity = LCS_length / max(len_a, len_b) (after lowercasing) + 0.1 × (shared leading characters / max length) ← prefix bonusThe prefix bonus rewards names that start the same way. Your read_fille and read_file share 8 leading characters, so:
LCS part: 9/10 = 0.90prefix bonus: 0.1 × 8/10 = 0.08similarity: 0.98 → suggested
vs "write_file": LCS = 7 ("r_e_file"), prefix = 0 → 0.70 (also above threshold, but 0.98 wins — best match is suggested)Your middleware suggests the best-scoring registered name, but only above a threshold, default 0.4. Below it, silence beats a nonsense suggestion for you.
How LCS is computed
Section titled “How LCS is computed”This is the classic dynamic-programming table. Finding the LCS by trying every subsequence would explode combinatorially.
The insight that makes it cheap for you: the LCS of two strings can be built from the LCS of their prefixes.
Fill a table where cell (i, j) is the LCS length of the first i characters of A and the first j of B:
f i l e ← B = "file" 0 0 0 0 0 f 0 1 1 1 1 i 0 1 2 2 2 cell rule: l 0 1 2 3 3 chars match → 1 + up-left neighbor e 0 1 2 3 4 else → max(up, left)Each cell costs constant work, so your whole table is O(len × len) time. Only the previous row is ever read, so only two rows are kept: memory O(min(len)).
Your tool names are short, so this runs in nanoseconds. The same algorithm scales to diff tools over whole files.
Run the full matrix yourself for the typo pair "fille" and "file". The double l surviving is visible in the numbers:
f i l e ← B = "file" (columns) 0 0 0 0 0 f 0 1 1 1 1 i 0 1 2 2 2A = l 0 1 2 3 3 the second 'l' row repeats the"fille" l 0 1 2 3 3 first — an unmatched char can e 0 1 2 3 4 never raise the count, only the matching 'e' at the end can: 3 → 4Bottom-right: LCS = 4, so 4/5 = 0.80. Your prefix bonus adds 0.1 × 3/5 = 0.06, because they share f, i, l up front.
That is 0.86, comfortably above your 0.4 threshold.
And similarity(), end to end, in its exact order of operations:
similarity(a, b): 1. both empty → 1.0; one empty → 0.0 (degenerate cases first) 2. lowercase both; equal → 1.0 (free fast path — most real tool-name matches are typos, not case) 3. lcs = LCS-length over the char vectors (the two-row DP above) 4. prefix = count of equal leading characters (zip + take_while — stops at first mismatch) 5. score = lcs/max_len + (prefix > 0 ? 0.1 × prefix/max_len : 0) 6. clamp to [0, 1]The suggestion itself is then a max-search over your registered names. The best scorer wins, and it is suggested at all only if it clears your threshold.
You tune it with with_threshold: lower it for more suggestions and more false positives, raise it for fewer of each.
2. Word overlap
Section titled “2. Word overlap”Your built-in memory store (memory) must rank which stored entries a new question of yours should surface. No embeddings, only counting shared words.
Each candidate entry scores:
score = 0.5 × entry.relevance ← its stored importance + 0.4 × (query words found in the entry text / total query words) + 0.3 × (1 if any tag contains the whole query, else 0) + 0.1 ← baselineWorked example for you. Query "deploy the blue service", two entries:
| Entry | relevance | word hits | tag? | score |
|---|---|---|---|---|
"deploys go to the blue cluster" (tags: infra) |
0.8 | deploy, the, blue → 3/4 = 0.75 | no | 0.5×0.8 + 0.4×0.75 + 0 + 0.1 = 0.80 |
"meetings are on tuesdays" (tags: deploy) |
1.0 | the → 1/4 = 0.25 | yes | 0.5×1.0 + 0.4×0.25 + 0.3 + 0.1 = 1.00 |
Your scorer, line by line, including the two details the formula alone does not show:
retrieve(query, limit): 1. query_words = query, lowercased, split on whitespace 2. snapshot the entries under the lock; DROP the lock; score the snapshot (readers never block writers — the contract is pinned by a test) 3. per entry: word_matches = how many query words appear as SUBSTRINGS anywhere in the entry's lowercased text query_bonus = word_matches / max(query_words.len(), 1) tag_bonus = 0.3 if ANY tag contains the whole query as a substring score = relevance×0.5 + query_bonus×0.4 + tag_bonus + 0.1 4. sort by score, descending; take the first `limit`The substring detail in step 3 is doing quiet work for you: "deploy" matches "deploys", "deployed" and "deployment".
That is word stemming for free, at the cost of also matching "deployed" inside unrelated words.
The snapshot discipline in step 2 is the whole cheap-store trick: scoring a large snapshot costs your writers nothing, so your retrieve stays lock-friendly however slow the scoring grows.
Two limits in your built-in store are there on purpose. Matching is transparent word overlap and never semantic, and popularity and forgetting live outside your scorer.
A retrieval of yours that actually matched records an access stamp, and the consolidation pass (0.3.2: decay, merge, prune) folds those stamps into access_count.
It also decays relevance by category, so being retrieved rewards an entry. The matching itself stays word counting, small enough for you to verify by reading.
Notice what the example exposes: the tag bonus and high relevance let a less relevant entry win. That is the honest character of word counting.
It measures overlap, not meaning, and the weights are where you tune what “relevant” means.
Matching is case-insensitive substring containment: your query word counts if it appears anywhere in the entry text.
The +0.1 baseline guarantees nothing scores exactly zero, so your top-k retrieval always has a full page of candidates to return, even on weak matches.
When overlap is not enough for you, the store is a trait. A vector database with embedding similarity slots in behind the same three verbs, and your engine never knows the difference.
3. Balanced-brace scanning
Section titled “3. Balanced-brace scanning”Your models asked for JSON sometimes answer with the JSON embedded in chit-chat: Sure! {"answer": 42} hope that helps.
Your lenient structured-output extractor (structured output) recovers it by scanning the text for a {...} block whose braces balance.
It tracks depth, so a { inside a string literal does not count, and it takes the first block that opens and closes evenly.
Try the strict paths first: a tool-call argument, then a whole-text JSON parse. Your scan runs only when those fail.
It is cheap, order-sensitive, and wrong only in prose that contains unbalanced-looking braces, which prose rarely does.
Why order matters
Section titled “Why order matters”Line the four matchers up and a design decision falls out:
| Matcher | Unit | Order-sensitive? | Used for | Why that fits |
|---|---|---|---|---|
| LCS | characters | yes — order is the signal | typo suggestions | typos preserve order, break spelling |
| Word overlap | words | no (counting) | memory relevance | word presence ≈ topic, order is noise |
| Jaccard (sets) | words | no (sets) | convergence | rewording must not hide repetition |
| Brace balance | characters | yes — nesting is the signal | JSON extraction | structure is literal |
The rule the table encodes: choose your insensitivity deliberately. When “same characters, same order-ish” means same intent, as with typos and JSON, use an order-aware matcher.
When your intent survives rewording, as with topics and repeated answers, throw order away. An order-sensitive matcher on reworded text would miss everything.
Each loopctl matcher sits on the side of that line its question requires.
All four share the family virtues: no model calls, no dependencies, sub-millisecond, and failure modes you can name in one sentence.
For the questions where that is not enough for you, the trait boundaries (memory store, extractors) are where heavier machinery plugs in.
Related pages
Section titled “Related pages”-
Memory: the store your word-overlap scorer serves.
-
Middleware: where your suggestion layer sits.
-
Windows, averages, and similarity: Jaccard’s full story.
-
Structured output: the strict paths around your brace scanner.