Telling a plan-mode model what its Redis inventory really is
Because the rows are groupings from a bounded scan, the plan rules carry a sentence saying so, what a command may name instead, and how far the list reaches.
A plan run against a seeded local Redis read seventeen real key prefixes through the
provider, was shown them under a heading that called them tables, and drafted
KEYS user:*. The grounding worked. The noun was wrong, and the model read the
sentence it was given correctly. On a key-value store the inventory a plan run is
handed is not a list of keys; it is a summary this server computed.
What grounding means on a key-value store
Before a plan run’s first turn, the server reads the connection’s schema through the provider and writes it into the prompt. On PostgreSQL that is catalog reads plus a statistics read. On Redis it is one call, because there are no statistics this run knows how to read here, and it comes out of the same per-run statement budget and is audited the same way as every other read.
What that call returns is built by getSchema(), and every step of it is a decision
rather than a lookup. Discovery uses cursor-based SCAN with COUNT 100, never
KEYS *, because KEYS * is O(N) and blocks the whole server. Each key found is
grouped by everything before its first colon plus :*, so user:123 and user:456
collapse into one row named user:*. Each group is probed with TYPE until three
distinct value types have been observed. The result is sorted by descending key count
and emitted as one synthetic table schema per prefix, each with three columns: key,
value and type. Indexes are always empty, and table statistics return an empty
list, because Redis has neither.
None of that came off the server as an object. Redis has no schema to read. The row
user:* exists because this code made it.
The sentence the plan rules add, and why
Two things were wrong in that first run, and they sit on different axes. Both are driven by what the provider declares, not by a check on the connection’s type.
The first is the noun. This product records every schema in one shape, TableSchema,
and the prompts had been using that shape’s name as a word. ProviderLabels.entityName
has carried the right word all along - “Table” on the SQL engines, “Collection” on
MongoDB, “Datasource” on Druid, “Key Pattern” on Redis - and only the browser was
being shown it. The fenced inventory header now takes it, so a Redis run reads
“17 key pattern(s)”.
The second is that a noun alone still does not tell a model that a key pattern is not
addressable. No fact about Redis could, either, because the grouping is not Redis’s.
So the capability tablesAreDerivedGroupings carries it. It is true on Redis and on
the embedded LibreDB store and nowhere else, and where it is true the plan rules carry
one extra sentence. That sentence says three things: the rows are groupings this
server derived from a bounded scan, a statement may instead name a whole key or a
pattern scan in whatever form the engine offers, and the list is one reading’s reach
rather than the database’s contents.
What the sentence deliberately does not contain is any command, any command name, or a prohibition on one. A rule reading “never use KEYS” would be engine trivia in a prompt. It goes stale, it teaches nothing about the next command, and a run that knows what the rows are can choose for itself.
Prefixes are one reading’s reach, not the database contents
The bound is a number in the source: maxScan = 1000, and the scan loop stops when
the cursor comes back to 0 or when a thousand keys have been seen, whichever happens
first. A prefix whose keys all live beyond that cap does not appear in the inventory at
all. The provider doc calls that a deliberate bound rather than a bug, and the plan
rules repeat it in words to the model, because the failure mode of an unstated bound
is a model that treats the list as exhaustive and reports an absence it never measured.
The same bound explains a discrepancy a person can see in the browser. The schema
tree’s key count for a prefix comes from getSchema(), which loops the cursor over up
to a thousand keys. The Scan Keys action emits SCAN 0 MATCH user:* COUNT 50, which is
one cursor iteration, not a listing. An iteration can legitimately return a non-zero
cursor and no keys, so the panel can show a cursor and nothing else while the tree says
the prefix has keys. Neither number is wrong. They have different denominators.
What the model is asked to name instead
A plan run’s deliverable is one runnable statement in a single fenced block tagged with
the connection’s canonical type id, with the rationale after the block and no name in
it that is not in the inventory. On an engine whose query language is json the
wording changes and the tag does not: the run is asked for one statement or command in
the engine’s own language and told the engine speaks no SQL.
The shape of that command is its own constraint here. executeRedisCommand reads the
whole body as one command, so a plan run on 2026-08-22 that drafted
1) KEYS session:*
2) GET session:1
produced ERR unknown command '1)' when someone ran it. The provider’s
statementLanguage label now names the two things that made it unrunnable, the list
numbering and the second command, alongside the two accepted forms: a plain command,
and the lossless JSON command object {"command": ..., "args": [...]}.
The same withholding runs through the schema explorer, for the same reason and without
the agent involved. Profile Table and Generate Test Data are hidden wherever
tablesAreDerivedGroupings is true rather than left to answer HTTP 400, and Redis
offers no per-row maintenance action at all. Generate Code stays, because it names
the row rather than addressing it. A run that cannot answer from the inventory has a
second legitimate ending: a line beginning NO STATEMENT: that says what is missing
and asks the one question that would unblock it.
Who runs the command that comes out
Nobody in this loop does. Agent AUTO mode - the tool-using run - does not open on
Redis at all. The read-only profile that mode depends on is database-native,
and queryReadOnly exists on the PostgreSQL, SQLite and DuckDB providers only. It
appears nowhere in redis.ts, so a Redis agent run ends engine-unsupported. Plan
mode opens on every connection instead, and it is toolless: it executes nothing, and
its inventory is that bounded thousand-key scan whose rows are derived groupings rather
than objects a command can be given. Those are two different modes, and the distinction
is published on the features page.
That leaves a person holding a drafted command. Two things meet them there. The
provider has no read-only guard - the generic call() dispatch executes SET, DEL
and FLUSHALL exactly as it executes GET, and access control is expected to come
from the Redis ACL, which is what the connection dialog’s Username field selects. And
the execution confirmation gate reads the buffer the same way the provider does,
dropping comment lines and taking the first block, then asks before running anything in
its destructive vocabulary. A body it cannot parse asks rather than staying silent.
The drafted statement is also recorded, as a plan-statement-drafted event carrying
the command, the dialect, whether it is read-only and what the identifier check found.
The audit trail is admin-only, which is stated with the rest of the boundaries on the
security page. A plan run leaves a record of what it proposed. What
happened next is on the person who pressed Run.