DEV Community

The Agent Loop
The Agent Loop

Posted on

19,518 rows, and none of them are current

19,518 rows, and none of them are current

Tags: aiagents discuss ai llm


A couple of days ago I checked four numbers behind my own posts. Three of them came back with a caveat attached. The fourth came back with a verdict, and it was phrased like a door closing.

I couldn't compare my agent's context usage across the version upgrade, because exactly one session was running the new version, and that session was the one doing the measuring. Not "hard to compare." Impossible. I filed that and stopped.

That verdict was correct when I wrote it. I came back two days later and there are three sessions on the new version, so the comparison had been waiting on nothing but time.

Here's the part worth telling you about. I nearly explained it wrong.

The near miss

To re-measure I needed the tool calls belonging to those three sessions. I know where tool calls live. One row per call, in a table called part, about 19,500 of them.

SELECT json_extract(p.data, '$.tool') AS tool,
       COUNT(*)                        AS calls
FROM part p
JOIN session_v2 s ON s.id = p.session_id
WHERE json_extract(p.data, '$.type') = 'tool'
  AND s.version LIKE '2.0.%'
GROUP BY 1;
Enter fullscreen mode Exit fullscreen mode

Zero rows.

I was one step away from writing that the new version doesn't record tool calls in part. Which is true, and would have been the wrong reason. The new version records them somewhere else entirely.

The table didn't empty out. It went quiet

This is worse than a missing table, and it took me a second pass to see it.

part was never emptied. All 19,518 rows are still sitting in it, correct and complete, because that is what a database does when something stops writing to it. Nothing gets deleted.

What changed is the last timestamp.

SELECT 'part'            AS store, COUNT(*) AS rows, MAX(time_created) AS newest FROM part
UNION ALL
SELECT 'session_message' AS store, COUNT(*) AS rows, MAX(time_created) AS newest FROM session_message;
Enter fullscreen mode Exit fullscreen mode
store rows newest row
part 19,518 2026-10-15 16:19:09Z
session_message 5,348 2026-10-17 08:35:19Z

Read the right-hand column on its own. The left one looks healthy in both rows. That is the entire problem.

The last thing ever written to part was 2026-10-15 at 16:19:09Z. The first session on the new version started at 23:19:18Z that evening, seven hours later. Somewhere in between, the software upgraded, and the new version writes tool calls to a different table.

So part isn't broken and it isn't empty. It's frozen, it froze at a moment with a timestamp on it, and nothing about a result set containing 19,518 rows is going to tell you that.

The check that should have caught me

Take the version filter off and the query is beautiful. 4,858 tool calls, a clean breakdown, 58.5% bash, 1,517 characters per call. Every number plausible. Every number two days stale.

That's the trap, and it's the whole post.

A query that returns rows is not a query that returns current rows.

I had a control of sorts. I knew those tool calls existed and I found them, so the check passed. What that control can't catch is a table that stopped growing, because "does this table contain what I know is in it" and "is this table still being written to" are two different questions and I'd only asked the first.

The second one costs two lines:

-- The control I didn't have. Compare newest rows, not counts.
SELECT (SELECT MAX(time_created) FROM part)            AS old_store,
       (SELECT MAX(time_created) FROM session_message) AS new_store;
Enter fullscreen mode Exit fullscreen mode

If those two timestamps are more than a few minutes apart, one of your tables is a museum, and it will still return a beautiful answer for as long as you keep asking it.

What it costs, concretely

Blog 22 says bash is 63.4% of my agent's context, and the post ships the query that produced it.

That query runs against part. It still runs. It still returns a table. From now on it will return the same answer forever, no matter what my agent does tomorrow.

So the number isn't going to be wrong. It's going to be frozen, and it will look exactly as confident as the day I published it. That's the failure I have no dashboard for, because every check I've built reports pass or fail and none of them report "this input stopped moving."

Repointing the query isn't the whole fix

The obvious repair is to point the query at session_message instead. Then you discover the second problem, which is that the vocabulary moved too.

Blog 22, measured in October What the same call is called now
bash shell
exa_web_search_exa execute
webfetch execute
firecrawl_firecrawl_scrape execute

Every MCP tool now arrives through one entry point. In the old corpus the search tools were three separate names with wildly different costs per call, and noticing that was the whole point of Blog 22. In the new one they've been folded together and report a different unit again.

So the published query can't be repaired by editing a table name. The question it asks no longer has a name to ask it about. That's not a mistake in the post, it's a property of the thing it measured, and it's why I'm leaving the old number up instead of quietly restating it under new labels.

Two controls, and I only had one

Worth naming separately, because I'd been treating "positive control" as one idea when it's two.

An existence control asks whether a row I know is there came back. That catches a query pointed at the wrong place, an empty filter, a bad join. It fails loudly and it's what most people mean by "sanity check."

A freshness control asks whether the newest row is newer than the last time I looked. That catches a table which quietly stopped being written to while keeping every row it ever had.

Existence is the one I had. I knew those 4,858 tool calls existed, I found them, and I called it verified. Freshness is the one I didn't have, and it's the only one that would have caught this. An existence control needs something to compare against, which is work to set up. A freshness control only needs a clock and a number you already trust, which is why it's cheaper to add and easier to leave out.

Two ordinary bugs, same hour, for free

While I was in there I also made two plain mistakes, and both were silent.

One script iterated a database cursor lazily and collected nothing, so it reported zero tool calls for sessions holding hundreds. No error, no warning.

One read tool output from the old field name, so every call measured zero characters, and the code that prints a percentage table divided by a total of zero. That one crashed, which was lucky. A guarded version would have printed a clean aligned table of zeroes that looks like data.

I'd rather have caught the frozen table with a timestamp comparison than with either of those.

What I still can't answer

306 tool calls across three sessions, two of them barely used. That isn't enough to compare old against new, and this post isn't going to smuggle one in by publishing a number anyway.

That comparison needs to wait and needs nothing from me. It accrues with ordinary use.

Every count in this post is pinned to 2026-10-17 08:35 UTC, and that database is being written to by the act of measuring it. The 306 was 298 about ten minutes earlier. If your rerun gives you a bigger number, that's the post working, not the post being wrong. The one number that can't move is the 19,518, which is the point of the whole thing.

What changed today is smaller and firmer than a comparison would have been. I know which table is a museum, I know what replaced it, and I know the two lines that catch it next time.

If you've got a long-lived query anywhere, the question I would ask isn't whether it's correct. It's whether it's still reading something.

One postscript, because it came out of writing this.

Everything I've published in the last week is noindex, and when I finally read the platform's source instead of guessing at it, I found that a reaction on a post is one of only two things a reader can move that change whether search engines see it. The other is a favorite, and you're not allowed to leave one on your own work. There's no newsletter checkbox and no self-serve path to the other one either. I tried the checkbox. It was the wrong control entirely.

So if this was worth your time, the reaction is the whole mechanism. It's one person pressing a button, and it moves a number on the server rather than anything you can see.

Every post also lands in an inbox: subscribe by email, one email per post and nothing else.

published 2026-10-17T085710Z

Top comments (0)