Skip to content

feat(agent): let plan mode read the database it plans against, and draft the SQL - #396

Merged
cevheri merged 4 commits into
mainfrom
feat/plan-mode-sql-generator
Aug 15, 2026
Merged

cevheri merged 4 commits into
mainfrom
feat/plan-mode-sql-generator

Conversation

@cevheri

@cevheri cevheri commented Aug 15, 2026

Copy link
Copy Markdown
Member

Plan mode was built to be the safe mode: it reaches nothing, executes nothing, and hands you a
statement you run yourself. What it did instead was lecture.

Asked "What are the popular films and actors?" against a PostgreSQL connection holding the dvdrental
sample database, it answered with a four-step generic investigation plan that would have read
identically against any database in the world, opening with "I have no schema inventory available to
this run and have not seen this database at all."

That is not a model failure. It is what the code asked for, and there were two independent causes.

Cause 1 — plan mode never read a schema, it scavenged one

investigation.ts skipped the whole context-establishing path for a planning run and consulted one
in-process map instead:

const inventory = heldSnapshotForConnection(record.connectionId);
if (inventory === null) return;   // schemaKnown stays false

heldSnapshotForConnection is an LRU held in the serving process, and the only thing that fills it is
a prior agent-mode run on the same connection in the same process. So plan mode was blind whenever
the user had not run agent mode first, whenever the server had restarted, and whenever a second replica
served the request. The safe mode's usefulness was conditional on having already used the unsafe
one.

#384 added PLANNING_SCHEMA_RULES for the grounded case. It fixed the wording of a path that is
rarely taken; it did not make the path reachable.

Cause 2 — even when grounded, the contract asked for a lecture

The mode's governing rule was:

"Answer with a plan in prose: what you would inspect, in what order, and what each step would
establish."

Nothing in either the grounded or the ungrounded half ever asked for a statement. Fixing cause 1 alone
would have produced the same numbered inspection plan with real table names in it.

Why it stayed broken

verifyPlanningGoal passed a planning run that left any non-empty prose. The lecture above scored
as a success. Nothing in the repository could tell the two apart.


What this changes

Plan mode grounds itself. A planning run now establishes context the way an agent run does —
server-side, before the first turn — and then takes its turns with no tools, exactly as before. The
model stays toolless; grounding is the server's work, so the capability gate, the probe and the
toolless-set assertions are untouched.

The contract that changes, and the one that does not. "A plan run reaches no database" is now
false and is rewritten wherever it was asserted. That sentence was never the safety property; it was a
proxy for it. What replaces it is the property the product actually sells: plan mode runs no
statement of the user's, writes nothing, and hands every statement it drafts to the user to run
themselves.
Reading a catalog is what the sidebar already does on every connect.

The profile is read from engine statistics, not from scans. Row counts, null fractions and
distinct counts come from pg_class.reltuples / pg_stats and sqlite_stat1 — catalog reads, zero
table scans. A COUNT(DISTINCT col) per column would have been a full scan, which is unacceptable in a
mode whose selling point is "open it against production without worrying".

Three honesty requirements follow, because these numbers are estimates:

  • everything derived from them is labelled an estimate in the text the model sees;
  • pg_stats.n_distinct is negative when it expresses a ratio, converted only against a row estimate
    and marked derived;
  • absence is not zero. A table never ANALYZEd has no statistics, and is listed as having none:
    "a table listed below as having no statistics is a table whose size is UNKNOWN to you — it is not an
    empty table, and nothing here entitles you to call it small." Silence read as zero would make the
    model refuse to join a real table.

No column values are read at any point. pg_stats carries sample values in most_common_vals; they
are not selected.

The deliverable is a statement. One runnable statement in a fence tagged with the connection's
canonical type-id (the tag rich-text.tsx already reads, so the editor hand-off is never lost to a
tag it does not know), rationale after it, no identifier absent from the inventory. Plan deliverables
are a total record over AgentRunWorkflowType, so a new workflow stops the file compiling until
someone decides what a plan of it hands back. operations is the exception by decision: it reads no
schema and composes no SQL.

The refusal is a first-class ending. When the inventory does not support the question the run
writes no statement and begins a line with NO STATEMENT:, naming what is missing and asking the one
question that would unblock it. A convention in the output rather than a tool, which is what lets a
toolless mode make its two outcomes mechanically distinguishable.

The statement is a ledger fact, not a parse. A new plan-statement-drafted event carries the SQL,
the dialect, the guard's classification and what validation found. This closes B44 — the browser no
longer depends on the model having fenced its SQL for the editor hand-off to exist.

The verdict stops accepting a lecture. A new no-statement shortfall: a planning run passes when
it produced either a drafted statement or an explicit refusal. Prose with neither is a shortfall,
however long. Without this the fix would regress silently the first time a prompt was reworded.

What review caught that the build did not

Two adversarial reviewers read the whole diff. All seven findings were verified against the code and
all seven were real.

  • The write marking was defeated in practice. The closing prose is still rendered through
    renderProse with onApplySql, so the same DELETE also got an unmarked "Apply to editor" button
    from rich-text.tsx directly above the marked card. The rail now withholds the prose hand-off for a
    closing entry whose statement the ledger already recorded; feat(agent): let a plan's SQL be copied, and reach the editor #389's fallback is untouched everywhere
    else.
  • A classification was stated as stronger than it is. readOnly === false is the statement guard's
    objection, which includes "two statements", "no statement" and text no single dialect reading
    settles — and the guard deliberately over-refuses some legitimate reads. The card said "This is not a
    read … can change or delete data". A pure SELECT ... #>> ... would have been shown to the user, and
    to a screen reader, as SQL that can delete their data. It now reports what the guard actually said,
    and shows the reason.
  • Three documents still published the retired contract after every other doc was rewritten, and one
    design record asserted the opposite decision as settled.
  • plan-statement.ts claimed an agreement with rich-text.tsx that did not hold on the fence info
    string. Fixed by extracting the shared predicate to src/lib/sql/fence-tags.ts rather than by
    editing the docblock, so the two readers cannot drift again.

Honest limits, stated in the docs

  • Grounding works on PostgreSQL and SQLite only — the dialects the catalog composers serve. On any
    other engine a plan run is ungrounded, knows it, and is steered to the refusal path rather than left
    to produce generic advice as though it were an answer.
  • A statement validated against the inventory is not a statement that will run. The inventory
    records what exists, not what the user's role may select from.

Not in this change

Exact COUNT(DISTINCT) scanning, sample rows and distinct-value listing (the depth decision was counts
only); a persisted connection-level data dictionary; and renaming the mode, which now under-describes
itself but touches storage, the rail, the docs and the API surface.

Verification

All six local gates green: format, lint, typecheck, knip, test, build.

…aft the SQL

Asked what the popular films were, against a connection holding dvdrental, plan
mode answered with a four-step generic investigation plan that would have read
identically against any database in the world. Two independent causes, and the
model was not one of them.

It never read a schema, it scavenged one. The planning branch of establishContext
consulted heldSnapshotForConnection and returned when it was empty — an in-process
LRU that only a prior AGENT run on the same connection fills. Cold process, second
replica, or a user who had not used agent mode first: blind. The safe mode's
usefulness was conditional on having already used the unsafe one.

And the contract asked for a lecture: "Answer with a plan in prose: what you would
inspect, in what order, and what each step would establish." Grounding it alone
would have produced the same numbered plan with real table names in it.

A planning run now establishes context the way an agent run does — server-side,
before the first turn — and takes its turns with no tools, exactly as before. The
MODEL stays toolless; grounding is the server's work, so the capability gate, the
probe and the toolless-set assertions are untouched.

"A plan run reaches no database" is now false and is rewritten wherever it was
asserted. It was never the safety property, only a proxy for it. What replaces it
is the property the product sells: plan mode runs no statement of the user's,
writes nothing, and hands every statement it drafts to the user to run themselves.

Statistics come from pg_class.reltuples, pg_stats and sqlite_stat1 — catalog
reads, zero table scans, because COUNT(DISTINCT) per column is a full scan and
this mode's selling point is that you can point it at production. They are
estimates and are labelled as such; n_distinct is converted only against a row
estimate and marked derived; and absence is not zero — a table never ANALYZEd is
listed as having none, because silence read as zero would make the model refuse to
join a real table. No column values are read: pg_stats carries sample values in
most_common_vals and they are not selected.

The deliverable is one runnable statement, fenced with the connection's canonical
type-id so the editor hand-off of #389 recognises the tag. Plan deliverables are
total over AgentRunWorkflowType; operations is prose by decision. When the
inventory does not support the question the run writes NO STATEMENT: instead — a
convention in the output, not a tool, which is what lets a toolless mode make its
two endings mechanically distinguishable.

The statement becomes a plan-statement-drafted event rather than a browser-side
fence parse, closing B44, and the verdict gains no-statement: a planning run passes
on a statement or a refusal, and prose with neither is a shortfall however long.
Without that the fix regresses the first time a prompt is reworded.

Adversarial review found seven defects and all seven were real. Two are worth
naming: the write marking was defeated in practice, because the closing prose still
rendered the same DELETE with an unmarked Apply-to-editor button above the marked
card; and readOnly === false was stated as "this is not a read — can change or
delete data" when it is the guard's objection, which includes two-statement text
and reads the guard deliberately over-refuses. A pure SELECT using #>> would have
been announced to a screen reader as SQL that can delete your data.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Grounds Plan mode with audited schema/statistics reads and records its SQL deliverable for validation and editor handoff.

Changes:

  • Adds PostgreSQL/SQLite catalog-statistics grounding.
  • Records and validates drafted statements or explicit refusals.
  • Updates the rail, verifier, tests, and documentation for the new contract.

Reviewed changes

Copilot reviewed 38 out of 38 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/lib/agent/investigation.ts Grounds planning runs and records drafts.
src/lib/agent/composed-sql.ts Composes statistics catalog reads.
src/lib/agent/context-snapshot.ts Enables server-side planning capture.
src/lib/agent/schema-stats.ts Reads and packs estimated statistics.
src/lib/agent/plan-statement.ts Extracts and validates plan SQL.
src/lib/agent/goal-verifier.ts Requires a statement or refusal.
src/lib/agent/tools.ts Adds audited grounding operations.
src/lib/agent/types.ts Defines the drafted-statement event.
src/lib/agent/run-store.ts Recognizes the new event.
src/lib/agent/run-service.ts Classifies drafts as narrative events.
src/lib/sql/fence-tags.ts Shares query-fence recognition.
src/components/rich-text.tsx Uses shared fence-tag logic.
src/components/agent/timeline.ts Folds drafts and refusals for display.
src/components/agent/AgentRail.tsx Adds marked statement/refusal cards.
tests/unit/lib/agent/types.test.ts Covers event serialization shape.
tests/unit/lib/agent/tools.test.ts Covers the grounding execution seam.
tests/unit/lib/agent/schema-stats.test.ts Covers statistics reading and packing.
tests/unit/lib/agent/plan-statement.test.ts Covers extraction and validation.
tests/unit/lib/agent/goal-verifier.test.ts Covers planning verdicts.
tests/unit/lib/agent/context-snapshot.test.ts Covers planning snapshot capture.
tests/unit/lib/agent/composed-sql.test.ts Covers statistics SQL composition.
tests/unit/components/agent-timeline.test.ts Covers timeline presentation.
tests/unit/agent-policy-gates.test.ts Preserves the toolless-model gate.
tests/isolated/agent-investigation.test.ts Exercises planning grounding end to end.
tests/isolated/fixtures/agent-eval-harness.ts Adds grounding/statistics fixtures.
tests/evals/real-model.ts Updates live-model expectations.
tests/evals/plan-grounding.test.ts Evaluates cold-process grounding.
tests/evals/investigation-arc.test.ts Evaluates statement/refusal outcomes.
tests/components/agent/AgentRail.test.tsx Covers cards and accessible warnings.
README.md Updates Plan-mode behavior.
docs/FEATURES.md Documents grounding limits.
docs/BACKLOG.md Retires completed gaps and adds B45.
docs/API_DOCS.md Updates the API mode contract.
docs/AGENT.md Documents runtime grounding behavior.
docs/AGENT_GUIDE.md Explains statements and refusals.
docs/AGENT_DEMO.md Revises the demo narrative.
docs/AGENT_DATA_FLOW.md Documents server grounding reads.
docs/AGENT_ANALYST_DESIGN.md Records the superseded decision.
Suppressed comments (1)

src/components/agent/timeline.ts:1111

  • drafted is global to the entire ledger, so one drafted event marks every planning closing as statement-backed. A supported resume can contain multiple closings (for example, a refusal appended before a crash, followed by a drafted statement after resume); the earlier refusal then skips refusal rendering and leaks the NO STATEMENT: protocol token. Pair each drafted event only with the immediately preceding closing entry.
    if (drafted) {
      // The prose the statement was read OUT of, so the deliverable is in this text
      // as well as on the card below it. The surface is told, because the per-block
      // control inside prose cannot say what it is applying.
      items[index] = { ...item, planStatementRecorded: true };

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/lib/agent/plan-statement.ts Outdated
}
if (open !== null) block = block ?? fencedBlock(open.tag, open.lines);

if (refusal !== null) return { kind: "refusal", detail: refusal };
Comment thread src/lib/agent/plan-statement.ts Outdated
*/
function fencedBlock(tag: string | undefined, lines: readonly string[]): FencedBlock | null {
const sql = lines.join("\n").trim();
return sql.length === 0 || !isQueryFenceTag(tag) ? null : { tag, sql };
Comment thread src/lib/agent/plan-statement.ts Outdated
// The delimiters are one character each in every grammar this reader is given,
// and a doubled delimiter inside is how all of them spell one literal character.
const inner = sql.slice(index + 1, span.terminated ? span.end - 1 : span.end);
return { text: inner.replace(sql[index] + sql[index], sql[index]), end: span.end };
Comment on lines +286 to +298
function nextInList(sql: string, after: number): number | null {
let i = skipTrivia(sql, after);
const alias = readSqlWord(sql, i);
if (alias !== null) {
i = skipTrivia(sql, alias.end);
// `t AS x, u`: the alias is one word further on than in `t x, u`.
if (alias.text === "AS") {
const named = readSqlWord(sql, i);
if (named !== null) i = skipTrivia(sql, named.end);
}
}
return sql[i] === "," ? i + 1 : null;
}
Comment thread src/components/agent/timeline.ts Outdated
Comment on lines +389 to +390
if (prose === undefined || !PLAN_REFUSAL_MARKER.test(prose)) return null;
return prose.replace(PLAN_REFUSAL_MARKER, "");
Comment thread README.md
Comment on lines +140 to +143
(`src/lib/agent/runtime.ts:199`). **Plan** mode opens on every connection — the model there is
toolless, runs no statement of yours, writes nothing, and drafts a statement for you to run
yourself — but the schema reading that grounds it takes the same path, so it is grounded on those
same two engines and says so plainly anywhere else.
… a marker that says nothing

All six were real, and four of them made a check weaker than the sentence
describing it.

A fence tagged for ANOTHER engine was taken as this run's deliverable. The reader
asked isQueryFenceTag, which answers "is this a query" and says yes to `mysql` on
a PostgreSQL connection; the recorder then stamped the event with the CONNECTION's
dialect, so the model's MySQL was filed as PostgreSQL and the run reported as
answered. fenceTagEngine now says which engine a tag NAMES — `sql` and an untagged
fence name none and so contradict none — and a block that names another one is not
a deliverable.

A bare `NO STATEMENT:` counted as a refusal. The whole point of the convention is
that a run which cannot draft says what it lacks and asks for it, so the emptiest
output a model can produce was satisfying the one check built to stop a run being
scored as answered when it answered nothing. The bar is substantive text and
deliberately not a question mark: "Tell me which column records the rental price."
is a proper request and carries none.

Two defects in the identifier reader, both of which reported the validation as
having checked more than it did. `replace` with a string pattern rewrites ONE
occurrence, so `"a""b""c"` came back as `a"b""c` and a real table was reported
unknown. And a quoted alias is not a word, so the alias step walked off it and
never reached the comma — `FROM film AS "f", payments` ended the list at `film`,
and an invented table hidden behind a quoted alias escaped the unknown-table
finding entirely.

The browser could call a run refused while the verdict called it no-statement. The
timeline read the marker with a fence-blind regex, and argued the two could not
disagree because it is consulted only when the ledger holds no statement. The
argument had a hole: a ```text block containing the marker records no statement
either. It now asks readPlanStatement — the same reader the server records with —
so there are no longer two readers to disagree. statement-guard is pure, so that
import costs the browser nothing but the module.

.env.example and DOCKERHUB.md still published "Plan mode is toolless, reaches no
database", and AGENT_DEMO.md still said plan mode "sends no statements at all".
Both are the retired contract on user-facing surfaces.

One of the tests replaced here asserted the bug: "a refusal marker with nothing
after it is still a refusal". And one fixture was self-contradictory — a helper
that hardcoded a ```postgres tag, used by a test running on a mongodb connection —
so the dialect check breaking it was the check working. The tag is a parameter
now, because it is a claim.
@cevheri

cevheri commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

All six verified against the code, all six real, all six fixed in f889000.

1. A bare NO STATEMENT: scored as a refusal — readPlanStatement now requires substantive text
after the marker; a bare token reads as absent and the verdict reports no-statement. The bar is
substance and deliberately not a question mark: "Tell me which column records the rental price." is
a proper request and carries none. A bare marker also no longer suppresses a real block written
beside it, since refusal-beats-block only applies to an actual refusal.

2. A conflicting engine tag was relabelled — the sharpest of the six. isQueryFenceTag answers
"is this a query", which is yes for mysql on a PostgreSQL run, and the recorder then stamps the
event with the connection's dialect. New fenceTagEngine in src/lib/sql/fence-tags.ts says which
engine a tag names, including through unambiguous aliases (pgsql → postgres, tsql → mssql).
sql and an untagged fence name none, so they contradict none; only an explicit engine can. A block
naming another engine is not a deliverable, and a conflicting block no longer hides the run's real
one.

3. Only the first doubled delimiter unescaped — replaceAll. Exactly as described: "a""b""c"
came back a"b""c and a real table was reported unknown.

4. Quoted aliases ended the comma list — the alias step now reads with readNamePart rather than
readSqlWord, and handles the bare quoted alias (t "x", u) as well as AS "x". Both forms of
FROM film AS "f", payments now name both tables.

Findings 3 and 4 belong together: each made the validation quietly weaker than the sentence
describing it, one by rejecting a real table and one by never looking at an invented one.

5. The marker inside a non-query fence — fixed by removing the second reader rather than by
teaching it about fences. src/lib/db/operations/statement-guard.ts imports only zod and the SQL
span/word helpers, so plan-statement.ts is client-safe, and timeline.ts now asks
readPlanStatement directly. There is no longer a pair that can disagree.

Worth recording why the old code's defence failed, because the reasoning was sound and the covered
set was not. Its docblock argued the two readers could not disagree since the regex is consulted only
when the ledger holds no statement — but a ```text block containing the marker records no
statement either, which is precisely your example. The regex survives for the display strip alone,
now downstream of the shared ruling.

6. Three surfaces still publishing the retired contract — .env.example:250 and
DOCKERHUB.md:107 fixed, and docs/AGENT_DEMO.md ("sends no statements at all") with them. You were
right that the PR body overclaimed: the sweep covered docs/ and the README and missed the env
sample and the Docker Hub page.


Two notes from fixing these, since both bear on how much the green checks were worth.

One of the tests I replaced asserted the bug: "a refusal marker with nothing after it is still a refusal". It passed, it was covered, and it pinned the wrong behaviour.

And one fixture was self-contradictory — a fenced() helper hardcoding a ```postgres tag, used by
a test running on a mongodb connection. Finding 2's fix broke it, which is to say the break was the
fix working. The tag is a parameter now, because it is a claim rather than syntax.

Gates green after the fixes: format, lint (0 errors), typecheck, knip, test (28/28 groups),
build, and coverage at 35799/35799 lines (100.00%).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 41 out of 41 changed files in this pull request and generated no new comments.

Suppressed comments (6)

src/components/agent/AgentRail.tsx:510

  • This caveat now contradicts the mode’s implementation: a grounded Plan run executes several catalog/statistics statements before the model turn. Say that the drafted statement (or no statement of the user’s) was executed, rather than claiming the run executed nothing.
      <p data-testid="agent-plan-statement-caveat" className="mt-1 text-[0.625rem] text-zinc-600">
        The run executed nothing. What was checked is what this run read of the schema, which records what exists rather
        than what your role is permitted to read.

src/components/agent/AgentRail.tsx:1436

  • This message is false for the documented ungrounded path (for example MySQL): no schema was read there, yet its required NO STATEMENT: response renders as though a read schema failed to answer. Use wording that covers both a missing inventory and an insufficient inventory.
                    <p className="text-[0.625rem] text-amber-300">
                      This run drafted no statement: it reports that the schema it read does not answer the question as
                      asked. What it says is missing, and what it needs from you, are in its own words below.
                    </p>

src/components/agent/timeline.ts:396

  • The fence-aware reader may select a later refusal, but this fence-blind replace always removes the first marker. For prose containing a marker in a ```text block before the real refusal, it mutates the example and leaves the actual protocol marker visible/copyable, contradicting the refusal-card contract. Strip the exact refusal occurrence found outside fences.
function readPlanRefusal(prose: string | undefined): string | null {
  if (prose === undefined || readPlanStatement(prose).kind !== "refusal") return null;
  return prose.replace(PLAN_REFUSAL_MARKER, "");

src/components/agent/timeline.ts:1117

  • drafted is run-global, so one plan-statement-drafted event marks every planning closing in the ledger as carded. A process can resume a running run after crashing between recording a closing and finishing it; if either attempt drafted SQL, unrelated closings/refusals are then misclassified and lose their prose editor affordance. Associate each draft only with the immediately preceding closing event.
    const item = items[index];
    if (drafted) {
      // The prose the statement was read OUT of, so the deliverable is in this text
      // as well as on the card below it. The surface is told, because the per-block
      // control inside prose cannot say what it is applying.
      items[index] = { ...item, planStatementRecorded: true };

src/lib/agent/plan-statement.ts:249

  • ONLY is a PostgreSQL table modifier, not the end of the table position. With this rule, valid SQL such as SELECT * FROM ONLY payments reports no referenced tables, so a missing table escapes unknownTables and the UI presents the identifier check as clean. Skip the modifier and continue by reading the following relation name.
const NOT_A_TABLE_NAME: ReadonlySet<string> = new Set(["ONLY", "LATERAL", "UNNEST", "VALUES", "SELECT", "DUAL"]);

src/lib/agent/investigation.ts:1202

  • This cache lookup verifies only the connection ID, even though an existing connection can be edited in place with the same ID and a different host/database. The Plan run can therefore receive and validate against the previous database’s inventory instead of taking the new grounding path. Key or validate held snapshots against immutable target identity before reusing them.
    const recorded = reusableSnapshot(record.events, record.connectionId);
    const held = recorded === null ? heldSnapshotForConnection(record.connectionId) : null;
    let snapshot = recorded ?? held;

…e boundary

The last commit gave the rail the server's own reader so the two could not disagree
about whether a run refused. It did that by importing plan-statement.ts, which
imports the shared statement guard, which imports zod v4 — and zod v4 probes
`new Function("")` at module load to decide whether it may compile validators. A
browser under a strict script-src fires securitypolicyviolation even though zod
catches the throw, so every page rendering an agent run took a CSP violation. The
E2E security-headers spec caught it in chromium and webkit; nothing else did.

The commit message for that change said statement-guard is pure "so that import
costs the browser nothing but the module". Its direct imports were checked and the
transitive closure is what the policy measures.

So the reading half moves to plan-draft.ts, which may import nothing but types and
the fence-tag predicate. plan-statement.ts keeps the validation half and the guard
with it. The rail, the verdict and the recorder all read through plan-draft, so the
one-reader property the last commit bought is unchanged.

Measured rather than argued: built both ways and grepped the client chunks for
zod's own `jitless` marker — one chunk with the old import, none with the new. The
e2e/security-headers.spec.ts run that failed in CI passes locally against this
build.

plan-draft-boundary.test.ts pins the allowed imports as an exact set rather than a
prefix rule. "Anything under @/lib/sql" would admit the grammar and span readers,
which are pure today and one commit from not being, and admitting a module for
where it sits is the reasoning that admitted the guard.
@cevheri

cevheri commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

Correction to my reply above, on finding 5.

I wrote: "statement-guard.ts imports only zod and the SQL span/word helpers, so plan-statement.ts
is client-safe."
That was wrong, and the E2E security-headers spec proved it in chromium and webkit:
script-src blocked eval.

zod v4 probes new Function("") at module load to decide whether it may compile validators. The
throw is caught, but a browser under a strict CSP fires securitypolicyviolation anyway — zod's own
source carries a comment about exactly this. So giving the rail the server's reader put an eval probe
on every page rendering an agent run.

The mistake is worth naming precisely, because it is the same class this PR was congratulating itself
for catching: I inspected the module's direct imports and concluded it was safe. The transitive
closure is what the policy measures.

Fixed in 5e07c27. The reading half moves to src/lib/agent/plan-draft.ts, which may import nothing
but types and the fence-tag predicate; plan-statement.ts keeps the validation half and the guard
with it. The one-reader property finding 5 asked for is unchanged — the rail, the verdict and the
recorder all go through plan-draft.

Measured rather than argued this time: built both ways and grepped the client chunks for zod's own
jitless marker — one chunk with the old import, zero with the new — and the failing
e2e/security-headers.spec.ts passes locally against this build.

tests/unit/lib/agent/plan-draft-boundary.test.ts pins the allowed imports as an exact set rather
than a prefix rule. "Anything under @/lib/sql" would admit the grammar and span readers, which are
pure today and one commit from not being — and admitting a module for where it sits is the reasoning
that admitted the guard in the first place.

…, not the id

Closes B45, which this PR is what makes urgent.

`heldSnapshots` was keyed on the connection id alone, and nothing in an
`AgentContextSnapshot` records WHICH database a reading came from — it carries an
id, a fingerprint, a time and the tables. A saved connection re-pointed at another
database keeps its id, so this process served it the previous database's inventory
until the entry aged out or the server restarted. Editing a connection to aim at
staging instead of production is an ordinary thing to do, and the id does not
change when you do it.

Before the grounding work that cost a plan run a misleading prose plan, which names
tables the user can see are not theirs. Now the hold is the ground a drafted
statement is VALIDATED against, so a statement checked against the wrong catalog
comes back with no unknown names and the card reports it as checked — a confident
answer about a database nobody looked at. That is the class of claim this whole
design item exists to stop making, which is why it is fixed here rather than left
in the backlog.

`connectionIdentity` keys the hold on engine, host, port, database, service and
instance name, and the role. Three decisions:

- the FINGERPRINT is untouched. It must stay a function of the inventory, or two
  identical builds stop agreeing; the identity belongs in the key.
- over-keying is the safe direction. A miss costs one catalog read, because a plan
  run captures its own inventory when the hold has nothing for it; a false hit
  costs an answer. So `user` and `agentUser` are in it — a least-privilege role
  sees a different catalog.
- the password is NOT, because rotating a credential does not change which database
  this is. The key is hashed because `connectionString` can carry one, and a
  process-lifetime map should not hold a password as a key.

Also: the refusal card said "it reports that the schema it read does not answer the
question as asked". A run on any engine but PostgreSQL and SQLite is ungrounded and
refuses because there was no inventory at all — no schema was read. The card cannot
tell the two apart, so it now says only that no statement was drafted and leaves
the reason to the run's own words below it.
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit da015b4 into main Aug 15, 2026
23 checks passed
cevheri added a commit that referenced this pull request Aug 17, 2026
docs/AGENT.md's known-limitations list is held to the backlog in both
directions by tests/unit/agent-documentation.test.ts: every M2 deferral
must be named here, and every id named here must exist there. B44, B45
and B46 were added to the backlog by the demo-script drive and had no
citation, so the gate was red.

They belong in the paragraph that already frames this category - defects
found by driving the product against a live model in a browser, which
every gate in this repository passes over. That paragraph also still
said two entries followed the browser-driven six; B42 was deleted by
#396, so it is one.
cevheri added a commit that referenced this pull request Aug 17, 2026
…r it (#407)

* feat(agent): read the workflow off the objective instead of asking for it

The rail asked for two independent things before the user had typed anything:
the mode, and the workflow. The workflow tabs rendered ABOVE the objective
textarea, so a user was asked to classify a question they had not written yet.

The axis itself stays. It is not decoration: in agent mode it decides the tool
set, the budget and the goal verifier, and `operations` has no SQL and no schema
inventory at all. What changes is who decides it and when.

  - `classifyAgentWorkflow` reads the objective and names one of the five
    canonical workflows. One shot, no tools, no streaming, bounded by its own
    ceiling. It NEVER throws and never blocks a run: a model error, a timeout,
    an empty reply or a reply that is not one of the five all resolve to an
    investigation marked `unclassified`, which the surface says as what it is
    rather than presenting as a verdict.
  - `POST /api/agent/classify` is a separate endpoint rather than a step inside
    the open request, because the rail must know whether to show the consent
    step BEFORE a run exists. It carries no new authority: the client already
    sends `workflowType` on the open request.
  - `workflowSource` records how the workflow was decided, so the provenance
    line and the way out of it survive a reload and are read off the run rather
    than off the click that opened it. A header written before the field folds
    to `chosen` - those runs did carry a workflow their caller sent.

Consent moves rather than widening. The pre-start auto-execute checkbox is gone;
it now appears only in the consent step, which by construction exists only on
the `data-analysis` branch in agent mode. A checkbox offered where
`present_answer` is not has shipped once already. Because classification
happens before the run opens, consent asked between the two is still consent
given at open time, so the invariant that no later request may widen a run is
untouched.

Naming a workflow under Advanced skips classification entirely - no latency, no
model call - so the user who knows what they want never waits on the least
reliable component. The pre-start budget figures are hidden while Automatic is
selected: a specific ceiling shown before the workflow is known would be a
claim the rail cannot keep.

* fix(agent): keep the promises the inference surface makes — a stop, a reading, a connection, an announcement

Five findings from the review of #407, all confirmed against the code.

1. A refused stop no longer opens a replacement. `run.cancel()` caught every
   failure and resolved normally, so awaiting it in `beginRun` established only
   that the DELETE had been attempted: a "change" whose stop the server refused
   left the original run executing beside its replacement, on the same
   connection, spending its own budget, with the rail following the new one. It
   now reports whether the server accepted the request, the replacement opens
   only if it did, and a failed change says the run is still going.

2. The classification's OUTCOME is persisted, folded and rendered off the run.
   It was local state defaulting to "classified", so any surface that had not
   made the reading itself — a reload, a second reader of the stream — presented
   a failed classification as "read from your objective". `workflowReading`
   joins `workflowSource` end to end: the type with a documented default, the
   store's read and write side, the route (optional, unrecognised values refused
   with 400, echoed from the persisted record), the timeline fold and the rail's
   sentence. The default for a header that carries none is "unrecorded" — a
   third answer, because "classified" credits a reading that may have failed and
   "unclassified" asserts a failure nobody recorded and can contradict the
   workflow beside it.

3. A start snapshots its connection. `beginRun` read `connectionId` from a
   closure, and the consent step's Open runs in the latest render's, so a run
   could open against a database the rail no longer displayed — and the consent
   copy promises the statement runs "on the connection the run was opened on".
   The id, the name and the engine are taken at the click and carried on the
   held start, alongside the mode and workflow axes that were already frozen for
   that window. The copy names that connection, and the SQLite warning is read
   from it.

4. Two companion docs brought into line. `AGENT_DATA_FLOW.md` gains the row and
   the section for `POST /api/agent/classify`, which sends the objective to the
   model before any run exists — an egress the "short version" table did not
   list. `AGENT_GUIDE.md` no longer says the workflow is chosen before Start or
   that Analyze offers a pre-start checkbox; it describes Automatic, Advanced,
   the consent step, the "Opened as" line and its change control, and every
   stale line-number citation in it has been re-resolved.

5. The consent panel is announced. It is a labelled region — named by the
   sentence naming the workflow and the connection, described by the terms — and
   focus moves into it when it appears, rather than being left on a Start button
   that disables in the same commit. Cancel returns focus to Start, Open leaves
   it in the objective box, so neither exit strands it on a removed or disabled
   node.

Each fix lands with a behavioural test that fails against the defect, verified
by mutation.

* docs(agent): rewrite the demo script around what the 2026-08-17 drive saw

Every case in the script was notated "pick this workflow tab, then type this".
That tab row is gone: the workflow is read off the objective on the server, and
naming one yourself lives under Advanced. So the notation changes everywhere,
and with it the figures and claims 26 live runs contradicted.

Rewritten because the old text is false, not merely dated: case 3 ("nothing ran
on its own" - as Analyze, two reads ran), case 6 (no definition was named), case
15 (the checkbox is now a consent step raised after Start, for Analyze only),
case 18 (Assess, one statement, 0/45 and 0.0/135.0 s), cases 19 and 19b (a
plan-mode Operate run is ungrounded and prose-only by design), case 20 (a
mid-run stop ends cancelled and unanswered, with no partial report), case 22
(not performable on this setup at all).

Lightly corrected where the substance held: cases 1, 2, 11, 12, 13, 21 and the
seed-connection rule, which is "rebuildable server-side" rather than "listed in
the seed file".

Added: the classifier's measured agreement rate (17 of 21 objectives), the two
correction paths when the reading is wrong, and a hazard note - the
least-privilege role this file prescribes cannot see foreign keys, so case 1
asserts that a database with 18 of them has none.

Three pre-existing product defects recorded rather than fixed: B44 (the FK read
and the negative it licenses), B45 (every Optimize run scored unanswered), B46
(plan-mode grounding is workflow-dependent but documented as engine-dependent).

* docs(agent): cite the three defects the inference drive found

docs/AGENT.md's known-limitations list is held to the backlog in both
directions by tests/unit/agent-documentation.test.ts: every M2 deferral
must be named here, and every id named here must exist there. B44, B45
and B46 were added to the backlog by the demo-script drive and had no
citation, so the gate was red.

They belong in the paragraph that already frames this category - defects
found by driving the product against a live model in a browser, which
every gate in this repository passes over. That paragraph also still
said two entries followed the browser-driven six; B42 was deleted by
#396, so it is one.
@cevheri
cevheri deleted the feat/plan-mode-sql-generator branch August 20, 2026 20:53
cevheri pushed a commit that referenced this pull request Aug 24, 2026
…#465)

* feat(agent): drive a model that cannot emit tool calls by asking in prose

The capability gate had two answers — admit, or refuse — and it was refusing a
class of model that can do the work. `deepseek-r1` at 7b, 8b, 14b and 32b streams
fine and reasons well; it simply never emits a `tool_calls` field. The probe
established that honestly, and the gate turned it into "configure a different
model", which is the wrong conclusion: asked for a tool call in prose, those same
models produced the correct action **15 times out of 15**.

So the gate grows a third verdict rather than a second refusal. A run it admits
this way carries `toolProtocol: "prompted"` on its ledger header, and the loop
reads that to decide how to ask.

## Which refusals qualify, and why it reads `disproved`

Exactly one shortfall, and it must have been WATCHED rather than merely
unobserved: the model was handed a tool and answered without calling it.

The condition reads `refusal.disproved`, not `refusal.missing`, and that
distinction is the whole judgement. `missing` is what the run needed and did not
get — so a model that called no tool is *also* missing schema-valid arguments,
because there were no arguments to validate. `disproved` is the half the probe
watched fail, and it already excludes `structuredOutput` when tool calling was
never established. A first version keyed on `missing.length === 1` and rejected
precisely the models this exists for: every `deepseek-r1` size arrives with two
missing and one disproved.

Everything else still refuses. An endpoint that never streamed cannot be driven
in prose either — prose needs the same stream. Arguments watched to fail schema
validation are not fixed by changing how the call is requested.

## The protocol

`prompted-tools.ts` states the contract in prose, describes each tool the run
actually holds with the JSON Schema its arguments are declared against
(`z.toJSONSchema` off the same Zod schema the native path enforces, so the two
cannot drift), and parses the reply.

Three details are measured rather than guessed:

**The contract goes in a USER message, not the instructions.** DeepSeek's own
model card says to avoid system prompts. Moving it took the run from 0 tool calls
to 1 — the single change that made the path work at all.

**The parser scans by brace depth, tracking string literals**, because a regex
truncates nested arguments at the first `}`.

**The LAST object in the reply wins.** A reasoning model rehearses candidate
calls inside its thinking before committing to one, so the first object it writes
is usually not the one it means.

## Why the result is a user message too

A prompted call gets a `role: "user"` result rather than `role: "tool"`. There is
no tool-call id to answer: the assistant never declared one, and a real endpoint
rejects a tool result whose id it has not seen with a 400.

## The native path is untouched

`toolProtocol` is optional on the record and absent folds to `native`, so every
existing ledger keeps its meaning without a migration. A run whose model calls
tools takes exactly the code path it took before — the prompted branches are
behind one boolean read off the record. The positive verdict is still cached; the
prompted admission deliberately is NOT, because a model that starts calling tools
should be asked natively again rather than held to a decision made once.

Measured across six agent surfaces per model: on the prompted path `deepseek-r1`
answers Investigate and Operate at 8b, and Assess at 32b — surfaces that
previously could not open a run at all.

* fix(agent): let a run reach the bar its own workflow is judged against

Measured across 25 local Ollama models on all six agent surfaces — five workflows plus
toolless planning, 150 runs in one uniform pass. 78 cells answered. No model passed all
six, and the 66 failures are not spread evenly: `no-report` is 37 of them and appears on
every surface, while `no-plan-comparison` (7) and `no-table-profile` (4) concentrate on
the two workflows whose verdicts ask for a specific artifact.

Reading those ledgers, the runs were not refusing to do the work. They were finishing
one call short of a bar nobody had told them about, or wandering until their budget ran
out. Three mechanisms, from the same evidence.

## The bars are STATED, which is the #350/#356 rule this repository already keeps

`WORKFLOW_TOOL_RULES.operations` carries the shape and the comment: "the verifier's rule,
in the model's own terms … a rule the model is never told is a rule live runs fail". Two
workflows were missing it.

`database-assessment` said "profile the tables that matter", which reads as advice about
WHERE to look rather than the condition its report is judged by. 18 of 25 models failed
that surface, five of them composing a report having called no tool at all.

The wording NAMES THE TOOL, and that was measured rather than chosen. A first version
said "profile at least one table": `qwen3.5:4b` satisfied those words by composing
eighteen count statements by hand with `run_read_query`, and three more models went to
`inspect_schema`. All four had done what the sentence asked and none produced the
`table-profiled` event the verifier reads. Naming `profile_table` moved
`granite4.1:30b`, `granite4.1:8b`, `qwen3:4b` and `qwen3:8b` from failing to answered on
that surface. The second clause exists because a model composing counts itself is being
diligent, not lazy, and deserves to be told why that does not clear the bar.

`query-optimization` described both its instruments and never said the report is judged
on having used one. Its 6 `no-plan-comparison` ledgers have the arc this repository's own
gate test scripts, character for character: one `inspect_plan`, then a report.
`nemotron-3.5-lightning:30b` and `qwen3.5:9b` produced identical ones — a correct
diagnosis, and no `recommend_change` call at all. Stopping after the diagnosis is a fair
reading of "why is it slow" when nothing says otherwise, so now something does.

## A run that will not record what it read is narrowed to what would finish it

`AGENT_REPORT_REMINDER_NOTICE` already tells a silent run to report, and the ledgers show
why a notice is not enough: reminded and still holding everything, those runs went back
to reading, repeated a refused selector, or wrote strategy about varying one. So the
reminded turn narrows the choice instead of repeating the instruction, and a second
trigger covers the half a reminder cannot reach — a model reading itself out of budget
never stops talking, so it never earns a reminder. `AGENT_UNREPORTED_CALL_CEILING` is 12,
read off the distribution: across 43 answered runs the most tool calls any made was SIX,
while the runs that looped ran to 7, 9, 11, 20 and 57.

WHAT it narrows to is the part this measurement corrected. An earlier version left
`compose_report` alone, and on two surfaces that makes the run's own bar unreachable — an
assessment cannot produce `table-profiled` and an optimization cannot compare plans — so
it would trade `no-report` for a different failure, not a smaller one.
`AGENT_NARROWED_EXTRA_TOOLS` keeps whatever the verdict requires, which turns the
mechanism from a muzzle into a funnel: a narrowed run can only do the things it is judged
on.

Declaration and dispatch are read from one place, because a review caught the half that
was missing: narrowing only what the model is TOLD about left `handleCall` reading the
full selection, so a tool remembered from an earlier turn was still executed, and a live
run reached 25 calls after the ceiling fired at 12.

## And a run one call short is asked, once, at the moment it can still act

`compose_report` ends the run, so these are delivered INSTEAD of that call and the call is
not executed — the same shape as `AGENT_PRESENT_BEFORE_REPORT_NOTICE`. Holding two plans,
the run is given both artifact ids and asked to compare them; the stated rule had already
moved models to three and five plans without one of them making the call, so the ids are
named rather than described. Holding ONE plan it is asked for the second, or for an index
recommendation on the plan it has — never for a comparison, which it cannot make. Telling
a model to do the impossible is the mistake the reverted `ANSWER_NOT_PRESENTED` refusal
already paid for: the run then neither answers nor reports.

Both notices stay out of the way after one delivery, and the gate eval asserts that
directly: a model that ignores the nudge still reports, and the verdict is still the
honest `no-plan-comparison`. A nudge that could swallow a report would be trading that
verdict for `no-report`, which is worse.

## What is claimed, and what is not

Claimed: each mechanism fires where it was aimed, and the four Assess recoveries above
were measured on the same question and connection before and after.

NOT claimed: a score for any model. These models vary more between runs than these
changes move them — the same model on the same code produced 20 tool calls in one round
and 2 in the next, and a cell that passed in one sweep failed in the next. A per-model
claim needs repeats, and one cell measured properly is worth more than twenty measured
once.

11 new tests. 132 isolated, 161 evals, all 30 groups, lint, typecheck, knip and build
clean.

* fix(agent): tell a run that read the engine and cited none of it

The bottom of the measured table, which the previous round did not touch. Of 25 local
models on six surfaces, the ones scoring 0/6 to 3/6 fail differently from the ones scoring
5/6: they are not stopping short of a tool, they are reporting on the wrong evidence.

The clearest case, from the ledger: `mistral-small3.2:24b` on `operations` called
`inspect_operations` SIX times, every call completed, and then composed a report whose only
citation was the schema inventory. `verifyOperationsGoal` asks for one `source: "artifact"`
reference and got none, so a run that had done the work scored `no-reading`. That cell had
passed before, which is what made it worth reading rather than filing under variance.

The same misdirection on the other five surfaces scores `empty-evidence`: the artifacts
cited came back with no rows while the run held ones that did. `qwen3.5:4b` and
`mistral-small3.2:24b` both landed there. One mistake, two shortfall names.

So the run is told, once, at the only moment it can still act — `compose_report` ends the
run — and the ids are NAMED rather than described, because the run already holds them and
looking them up is not the part it is getting wrong.

## Read from the CALL, not from the ledger

The report has not been recorded yet; that is the whole point of intercepting the call
rather than scoring the run afterwards. `citedArtifactIds` reads the arguments permissively
because `composeReportTool` validates them properly a moment later — all this has to decide
is whether the run is about to cite nothing it read.

## The distinction two integrity evals had to teach me

A first version filtered the cited ids down to the ones the run actually holds, and then
treated "cited nothing that resolves" as "cited nothing". That is wrong, and it is wrong in
the direction that matters: an id the run never produced is a DIFFERENT mistake, and
`composeReportTool` answers it with a precise refusal naming the invented id. Filtering
first put this notice in front of that refusal and swallowed it.

Two evals caught it — including the one where the thing being cited is a hostile row value
from a read result, which is exactly the case where the citation contract has to be the
voice that answers. So whenever a citation is present but unresolvable, this stays silent
and lets the tool speak. Only a call citing no artifact at all, or citing only artifacts
that came back empty while better ones are held, is held back.

## The #350 guard, again

It fires only when the run HOLDS something worth citing. A run that read nothing is told
nothing: asking it to cite a reading it never took is asking for the impossible, which is
the mistake the reverted `ANSWER_NOT_PRESENTED` refusal made and which cost those runs
their report as well as their answer. And a run whose every reading came back empty is
still offered one, because an empty reading IS an answer on some surfaces — the verifier
only rejects a report resting ENTIRELY on empties.

Offered once, then out of the way, and an eval asserts that directly: a model that ignores
the notice still reports, and the verdict is still the honest `no-reading`. A notice that
could swallow a report would trade that verdict for `no-report`, which is worse.

3 new evals. 164 evals, 132 isolated, all 30 groups, lint, typecheck and knip clean.

* fix(agent): read a plan statement the model wrote without a fence

Measured on two local models given the same planning objective, and the two shapes have to
be told apart rather than handled together.

`qwen3:1.7b` produced this, verbatim:

    sqlite
    SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' …

    The statement retrieves the names of the tables in the database. …

It had followed the contract far enough to put the engine tag on its own line and the
statement under it. It left out the backticks, `readPlanStatement` found no fence, and the
run was scored `no-statement` — the plan-mode bar — for a formatting slip wrapped around a
real statement. Planning mode holds no tools, so this was the only thing that could fail.

`qwen3:0.6b`, same objective, produced the other shape:

    sqlite
    <sqlite>
    The tables are: current_dept_emp, department, dept_emp, …
    </sqlite>

That is prose, and it has to keep failing. `plan-statement-drafted` is recorded whenever
this reader returns a statement — the guard and identifier checks ride along on the event
rather than gating it — so a reader that accepted a sentence would score the run answered
while the user reads prose. Loosening the fence rule is only worth anything if it stays
closed to that.

## What separates them

A leading query verb, checked against the START of the candidate rather than searched for
anywhere in it: a sentence ABOUT a select is not a select. `SELECT|WITH|EXPLAIN|SHOW|
PRAGMA|DESCRIBE|DESC` — the read-shaped openers a plan deliverable can have. Nothing a
guard would reject as a write is in the list, because a plan run's statement is a read by
contract and this reader should not be where a write first becomes plausible.

The candidate ends at the first BLANK line, because that is exactly where these models put
the explanation they were also asked for, and carrying prose into the SQL would hand the
editor something no engine accepts.

## The precedence that a test had to defend

The unfenced reading happens LAST — after the fenced block and after the refusal — so
every existing precedence is untouched: the first query-tagged fence still wins among
several, and a refusal still wins over an illustration the run declined to stand behind.

And it reads only the lines that were never inside a fence. A first version re-scanned the
whole text and broke the deliberate rejection of a fence tagged for another engine: the
`mysql` block a PostgreSQL run must not take was rejected as a block, and then its SELECT
was picked up as unfenced text and filed under this connection's dialect — the exact
mislabelling that rejection exists to prevent. The eval pinning it failed immediately,
which is the whole reason it is written that way.

No tag is returned, because the model named no fence and so told us nothing about the
engine; the recorder stamps the connection's own dialect, as it already does for an
untagged fence.

4 new tests. 48 plan-statement, 164 evals, 1089 agent unit tests, all 30 groups, lint,
typecheck and knip clean.

* fix(agent): ask the verifier what a report is missing before it lands

Seven fixes were measured across 25 local models today and all seven were one mistake
under different names. The model did the work and then missed a protocol detail on its
finishing move: wrote the report as prose, wrote SQL without a fence, cited the schema
inventory instead of the six readings it had taken, took one query plan instead of two,
analysed a table with `run_read_query` instead of `profile_table`. Each cost the run
everything it had established, because the finishing move gets exactly one attempt.

Five of those were answered with a hand-written notice apiece. That does not scale to the
shortfalls nobody has hand-written yet, and it drifts: the `database-assessment` bar this
file states in its own words was first phrased as an activity rather than a tool, and four
models satisfied the sentence while failing the check.

So the notice is DERIVED from the verifier instead. `VerifiableAgentRun` is a Pick over the
record, so the loop can assemble the run it is ABOUT to become — the ledger so far, plus
the report on its way in — hand that to `verifyRunGoal` unchanged, and read the shortfalls
this report WOULD earn. What the model is then told comes from the verifier's own
vocabulary, which cannot fall out of step with the check the way a duplicated sentence can.

## It changes no bar

A run still has to produce the profile, the comparison, the citation. All it gains is being
told what is missing while it can still act, instead of reading it on a verdict after the
run is over. `compose_report` ends the run, which is why the call is held back rather than
answered afterwards.

## Which shortfalls it speaks for

`no-table-profile` first, because it is the largest one with no purpose-written notice: 8 of
25 models failed `database-assessment` on it, four of them having called no tool at all. The
sentence names `profile_table` and a table from the run's own inventory, and says outright
that counts composed with `run_read_query` do not satisfy it — the wording measured to
matter, after `qwen3.5:4b` answered the earlier phrasing with eighteen hand-written count
statements.

`shortfallNotice` returns null for everything else, and that list is the #350 rule applied
case by case rather than a stub: `no-report` cannot arise here because a report is being
submitted, `cancelled` is not the model's to fix, and the shortfalls that already have a
purpose-written notice keep it, because those name artifact ids this generic sentence cannot.
New shortfalls plug in by adding a sentence, not a mechanism.

## Offered once

Three evals pin the contract: a run told what is missing goes on to clear its own bar; a run
that ignores the notice still reports and still earns the honest `no-table-profile`; and a
report that already meets its bar is not delayed by a turn. The two gate evals for this
workflow gained a second report turn for the same reason — the model declining the offer is
now part of what they assert, which makes them stronger than they were.

3 new evals, 2 extended. 167 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): ask for the plan an index recommendation rests on

The second shortfall to go through the verifier-derived notice, and the reason that
mechanism was worth building: this cost a sentence rather than a design.

Measured on `qwen3:8b` and `qwen3:14b`, whose Optimize ledgers are the same arc both times:
`inspect_schema`, a refused read, then `recommend_change` for an index — and no
`inspect_plan` anywhere. `verifyQueryOptimizationGoal` accepts an index recommendation only
when its evidence cites a plan the run inspected, so both scored `no-plan-evidence` having
produced a recommendation that may well be correct and simply is not established.

There is no plan id to name, because the run holds none. So the sentence asks for the
reading rather than for a citation the run cannot make: inspect the plan, recommend again
citing it, then report. That is the #350 rule in the small — every notice here has to be
answerable by the run receiving it.

The two gate evals for the index route gained a second report turn, for the same reason the
assessment gates did: the model declining the offer is now part of what they assert, and the
bar still closes on it.

168 evals, all 30 groups, lint, typecheck and knip clean.

* feat(agent): record the calls the server turns back

A held call was invisible. The drive can refuse to RUN a `compose_report` and answer the
model with a notice instead — cite a reading you took, compare the two plans you hold,
profile a table before you report — and because a call that is not run settles no step,
none of that reached the ledger. A reader saw a run that reported once, when what happened
was that it tried, was asked for something, and tried again.

That is a hole in the thing this ledger exists to be, and it is worst exactly where it
matters most: on the runs where the model DECLINES the offer, the entry this commit adds is
the only place the offer exists at all.

It also cost real time. `71dfe06` was measured on `qwen3.5:9b` and `qwen3:8b` and moved
neither — three runs each, all still `no-table-profile`. Whether the notice fired and was
ignored, or never fired, is the whole difference between a model limit and a bug in the
condition, and the ledger could not tell them apart because neither leaves a trace. This
makes that question answerable from the record rather than by adding logging to a build.

## The event

`call-held` carries the tool, the sentence the server said, and — where the notice came
from the verdict preview — the verifier's own name for what was missing. `shortfall` is
optional because the purpose-written notices answer conditions the verifier has no
vocabulary for: a run holding two plans and citing neither is not a shortfall, it is one
call short of one.

It is a NARRATIVE event, beside `plan-comparison` and `recommendation`, and for their
reason: nothing was executed and no step was settled. The run is telling the ledger a
decision it made, not recording an effect.

All four hold sites record through one helper, so a fifth notice cannot be added silently.

The timeline renders it under the existing `refused` tone rather than a new one — "the
server did not run this" is what happened, and a second colour for one fact would be a
second vocabulary to keep in step.

The compiler found every place this had to be declared: the store's exhaustive kind record,
the narrative union, the timeline's switch, and the contract fixture in `types.test.ts` —
which is typed as a total record over the union precisely so a new kind cannot be added
without one.

168 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): recover a tool call the model wrote as its payload

The largest measured loss, and the shape it actually has.

`no-report` was 37 of 66 failing cells across 25 local models on six surfaces — the only
shortfall appearing on every surface. Reading 36 of those ledgers, SEVEN had written a
complete `compose_report` payload into their closing prose, fenced as JSON, instead of
calling the tool. Seven different families: `deepseek-r1:14b`, `deepseek-r1:32b`,
`granite4.1:30b`, `lfm2:24b`, `nemotron3:33b`, `qwen3:0.6b`, `qwen3:1.7b`. `granite4.1:3b`
did it too and appended "(The compose_report tool was successfully used with the required
structure, producing the above report.)" — it believed it had called the tool.

Every one of those runs had done the work. The reading was taken, the claim was written,
the evidence was cited. Only the channel was wrong, and the run scored as having
established nothing.

## Identified by schema, because nothing else can name the tool

`readPromptedAction` cannot help: it looks for an `{"action", "arguments"}` envelope and
these models wrote the bare arguments. So `readPromptedPayload` matches a candidate object
against the input schema of each tool the run HOLDS. That is the only way to know which
tool a bare payload belongs to without guessing at it.

Two refusals matter more than the recovery, and both are pinned by tests:

**Ambiguity is not resolved.** A payload satisfying two held tools recovers nothing.
Choosing between them would be the reader inventing an intent the model never expressed.

**Recovery grants no authority.** What comes back is a name and an unvalidated input, put
through the same `AGENT_TOOL_DEFINITIONS` schema and the same audited pipeline as a native
call. A recovered report citing an id this run never produced is refused by the citation
contract exactly as it would be. That is also what bounds the injection case: a hostile row
value the model has quoted back can be recovered as a call, and it then meets every check a
call meets — including the one that refuses evidence this run did not produce. Recovery
reads intent; it does not confer trust.

## Read on both protocols, and only when the model said nothing else

Those seven were NATIVE tool callers. They emit `tool_calls` for other tools and hand-write
this one, so restricting recovery to the prompted path would have missed the case it was
built for.

It runs last — after a native tool call, after the prompted envelope — and only when the
turn produced no call at all. A model speaking the protocol it was given is unaffected,
which is the property that has mattered most on this workload: these models are sensitive
enough to their prompt that changes for everyone have measurably moved models that were
already passing.

The LAST fitting object wins, for the reason the envelope reader also reads backwards: a
reasoning model rehearses payloads inside its thinking before committing to one.

7 new tests. 21 prompted-tools, 168 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): narrow a held run to the tools that would clear its verdict

The `call-held` entry answered its first question within minutes of existing, and the
answer changed what to do next.

`71dfe06` had been measured on three cells and moved none of them — `qwen3.5:9b`,
`qwen3:8b` and `qwen3.5:4b`, three runs each, still `no-table-profile`. Whether the notice
fired and was ignored or never fired at all is the difference between a model limit and a
broken condition, and nothing in the ledger could tell them apart. With holds recorded, the
same cells say it plainly:

  qwen3:8b     no-table-profile   holds=2   tools=[inspect_schema, inspect_schema]
  qwen3.5:9b   no-table-profile   holds=1   tools=[]
  qwen3.5:9b   OK                 holds=1   tools=[... profile_table x6]
  qwen3.5:4b   no-table-profile   holds=2   tools=[inspect_schema x4]
  qwen3.5:4b   no-table-profile   holds=1   tools=[inspect_schema x2, run_read_query x3]

The notice fires every time. `qwen3.5:9b` took the offer on one run and answered after six
`profile_table` calls, which is the mechanism working exactly as designed. On the runs that
failed, the model heard it and spent the turn somewhere else entirely.

So the notice was never the missing part. The wandering after it was — and this repository
already had the answer to wandering.

## Pairing two mechanisms it already had

A held call now also narrows the run, using the same `AGENT_NARROWED_EXTRA_TOOLS` the
unreported-call ceiling uses. That record is keyed on what each verdict ACCEPTS, so on
`database-assessment` the run is left exactly `profile_table` and `compose_report`: it can
fix the thing it was asked to fix, or say nothing. It cannot go back to `inspect_schema`.

## Only where the fix is inside that set

`ShortfallAdvice.narrow` carries that per shortfall, and an eval caught the case the moment
it was written: `no-plan-evidence` is answered by calling `inspect_plan`, a READING tool
that the optimization verdict does not accept. Narrowing there would have removed the very
tool the notice asks for — telling a run to do something and taking away its means of doing
it, which is a mistake this file has paid for twice already and now cannot make silently,
because the flag has to be decided for every sentence added.

1 new eval, 1 extended. 169 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): refuse a catalog read that matched no object at all

The root cause this effort named a day ago and then did not fix, while shipping a notice
that helped models route around it. That ordering was the mistake; this is the fix.

The repository's own `query-optimization` eval objective is "Why is the employee listing
query slow?" — and there is no `employee_listing` object in the sample, because "employee
listing" is the name of a QUERY. Models called `inspect_schema` for it, the composed
`sqlite_master` read matched nothing, and `readCatalog` returned that as a COMPLETED step
with `rowCount: 0` and a citable correlation id. The model then cited it — correctly, since
the server had called it a success — and `restsOnlyOnEmptyResults` scored the run
`empty-evidence`. Three families produced character-identical ledgers doing exactly this:
`gemma4:26b`, `mistral-small3.2:24b` and `lfm2:24b`.

The model was not wrong. The server answered a question about a non-existent object with
success, and the run paid for it.

`profile_table` has had the right behaviour all along — `TABLE_NOT_INVENTORIED` refuses a
table the inventory does not list — and this is the same refusal for the same reason, with
its own closed code so the sentence can say something useful: check the inventory, and note
that a name from the objective may be the name of a query or a report rather than of a table.

## What it deliberately does not refuse

An empty RESULT. `run_read_query` returning no rows has established something about the
data, and this workflow family has a whole eval file (`empty-result-detection`) about not
treating that as failure. The distinction is between a question about the DATA, which zero
rows answers, and a question about the CATALOG, which zero objects does not: nothing was
inspected, so there is nothing to cite. Both directions are asserted.

Grounding reads are exempt. A grounding read is the SERVER asking, and an empty answer there
is the honest state of a database with no tables — the drive reads that itself and says so in
its opening note rather than being refused by a check meant for the model's own calls.

## Why this ordering matters

An adversarial review of the last twelve commits put it plainly: a notice that tells the
model to cite a different artifact treats the symptom of a wrong server answer. Every fix in
this family should be checked against that test — is the run being helped to work around
something we got wrong, or is the wrong thing being fixed? Here it was the second, and it had
been sitting diagnosed in the log since this morning.

2 new evals. 171 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): let an operator raise the per-turn ceiling that was killing local runs

The largest measured loss that is unambiguously OURS, and one that makes every other number
suspect while it stands.

Across 25 local Ollama models on six agent surfaces, NINE runs ended
`status=failed, stopReason=model-timeout`. The model was still working; the drive stopped
waiting. Six were counted as `no-report` — the shortfall this whole effort has been chasing —
and four were on Optimize, the surface reported as hardest. The clearest case:
`deepseek-r1:8b` in PLANNING mode, cut 92 seconds into its first turn with a zero-event
ledger. Planning holds no tools, so thinking time is the only thing that can fail there, and
the prompt it was thinking about had just been enlarged by this repository.

`AGENT_MODEL_TURN_TIMEOUT_MS = 90_000` is not wrong about what it was written for. Its
docblock says "turns on this workload land in seconds, so a ceiling this far above them is
only ever reached by a call that is not coming back", and the 300-second two-event run that
motivated it was a real defect. It is wrong about a reasoning model on consumer hardware,
which is the transport this product now supports.

So the value becomes configuration and **the default does not move**: an operator who sets
nothing measures exactly what every published number was measured at.

## Two guard rails the repository imposed, both worth having

**The env read belongs in `config.ts`.** `tests/unit/agent-documentation.test.ts` asserts that
exactly one module in the agent tree reads `process.env`, and it failed the moment the read
went in beside the constant. The same test then required the variable in `.env.example` and in
`docs/AGENT.md` — the right demand, since a knob an operator cannot find is a knob that does
not exist.

**`execution-policy.ts` must stay client-safe.** Putting the function there and importing
`config.ts` for the read broke `bun run build`: that module is reachable from the browser
(`Studio.tsx` -> `use-agent-prefill.ts`), and `config.ts` imports `fs/promises`, so the
attempt pulled Node's filesystem into the client bundle. The dependency has to point from the
server-only module to the client-safe one, which is why the whole function now lives in
`config.ts` and imports the budgets rather than the reverse.

## What a bad value does

Ignored — not clamped, not thrown on. A mistyped variable must not take down the start path,
and a ceiling of zero or NaN would end every turn instantly, which is worse than the value the
operator meant to set.

A good value is capped just under half the smallest workflow deadline. `AGENT_WORKFLOW_BUDGETS`
guarantees every workflow can take at least two turns — an invariant this file's own test
asserts — so a ceiling past that would configure a run that cannot finish.

## Why this one before anything else

An independent read of the sweep put it plainly: while a fixed 90-second cap stands, every
measurement is systematically biased against reasoning models and against whichever surface
carries the longest prompt. Fixing anything else first means measuring through a known
instrument fault.

5 new tests. 79 execution-policy, 128 documentation, 171 evals, all 30 groups, lint,
typecheck, knip and build clean.

* feat(agent): let the endpoint enforce the prompted protocol instead of asking for it

The prompted path asks a model that cannot emit `tool_calls` for a single JSON object and
then parses whatever prose comes back. That works — measured 15 times out of 15 on the
`deepseek-r1` family — and it is the wrong place to be doing the work. On that path a
malformed reply is a total loss: there is no `tool_calls` field to fall back on.

`no-report` is 51 of the 104 failing runs across 463 measured runs on 25 local models, more
than the other seven shortfalls combined, and it is overwhelmingly a FORMAT failure. This
repository has answered it twice by reconstructing intent after the fact — `readPromptedAction`
for the envelope, `readPromptedPayload` for a payload written as prose. Constrained decoding
is what gives that a priori, and it was never tried.

## Probed before it was written

Asked of Ollama's OpenAI-compatible endpoint directly, with the schema below as
`response_format: json_schema`:

    {"action": "inspect_schema", "arguments": {}}

Conforming, and conforming again with `tools` present in the request. So the constraint is
available on the transport this product actually runs on, which is the question that decides
whether any of this is worth writing.

## Prompted path only, and that is the whole risk argument

`takeTurn` gains an optional shape, and it is passed only where `prompted` is true — where
`tools` is already `undefined`, so there is no interaction with tool calling to reason about,
and where the alternative is parsing free prose. The native path is sent not one different
byte.

That matters more than usual right now: 40 cells across 16 models have just been locked at
five consecutive passes each, every one of them on the native path. A change that cannot
reach them cannot cost them.

## The constraint and the parser disagree on purpose

`PROMPTED_ACTION_SHAPE` requires `arguments`; `actionSchema` keeps it optional. A constraint
is a demand the server makes of a model that can satisfy it. A parser is a reading of
whatever actually arrived — including from an endpoint that ignored the constraint entirely,
which is exactly the case the prompted path exists for. Tightening the parser to match would
throw away the tolerance that made this path work at all.

## What is not claimed

That it works end to end. The probe establishes the endpoint honours the schema and the suite
establishes nothing else broke; no run has yet gone through the application with it. The four
`deepseek-r1` sizes are its only real examination, and they are measured next — their single-run
baseline is 2/6, 2/6, 2/6 and 1/6.

171 evals, 132 isolated, 21 prompted-tools, typecheck and lint clean.

* Revert "let the endpoint enforce the prompted protocol": measured, and it costs the run

Constrained decoding works and is too slow to use here. Both halves were measured rather
than argued.

`deepseek-r1:8b` on Investigate, three runs with the constraint, all identical:

    CALL inspect_schema        <- the constraint held: a valid action, first try
    call-held
    END deadline-exceeded      <- 461s, 455s, 454s

The protocol was never the problem after this change; the clock was. Each turn now costs so
much that the run reaches its 450-second deadline having made ONE tool call. Format was
guaranteed and the run was lost, which is a worse trade than the one it replaced.

## The one-variable check, because two things had changed

The turn ceiling had been raised from 90s to 150s in the same session, so "the constraint is
slow" was an attribution and not yet a measurement. Same cell, same raised ceiling, constraint
removed:

    95s  succeeded
    461s deadline-exceeded

One pass at 95 seconds against nothing under 454 with the constraint. The constraint is the
cost, and it is reverted.

## What stays, and what the second run says

`takeTurn` keeps the `constrainTo` parameter, passed `undefined`. The seam is right — the
endpoint does honour `response_format: json_schema`, probed directly — and the cost was in the
model, not the wiring. A future model that reasons less per token could take it.

The second measurement also says something this change is not responsible for: without the
constraint the cell is 1 of 2, not 2 of 2. `deepseek-r1:8b` is unstable here on its own
terms, and that belongs to the `no-report` work rather than to this.

171 evals, typecheck clean.

* fix(agent): ask twice before letting a doomed report through

The verdict preview holds a `compose_report` whose own verdict would reject
it, names the missing artifact, and narrows the run to the tools that would
produce it. It fired once per run, and five repeats on three models say once
is not enough: `qwen3:8b`, `qwen3:14b` and `qwen3.5:4b` each lose
`database-assessment` 5 times out of 5, and all fifteen ledgers have the same
shape -- notice fires, run is narrowed, model reports again anyway, run ends
`no-table-profile`.

The second ask is bounded at two holds. An earlier attempt to repeat an ask
sat on the silence path, which every run traverses, and eighteen tests failed
because every run gained turns; it was reverted. This one fires only where a
report is being submitted that its own verdict would reject -- a run already
losing -- so a run about to pass cannot reach it. A model that will never
comply pays one extra turn before the same verdict it was going to get.

The bound is two rather than three because three was measured, in the gate
evals, to cost a non-complying run three wasted turns for no behavioural gain.

* fix(agent): hold a report against the table it is making claims about

The notice that holds a `compose_report` for `no-table-profile` named
`inventory[0]` -- the first table in the captured snapshot. On the sample
database that is `current_dept_emp`, a view the catalog read describes with no
columns at all, while the report `qwen3:8b` was trying to submit was about
`dept_emp` and its 450,000 rows. Five repeats, five losses, the notice firing
twice and being ignored twice: we were asking for a table the model had no
interest in, and it declined.

The table now comes from the pending report's own claims, matched on word
boundaries so `dept_emp` is not found inside `current_dept_emp`. That is both
likelier to be acted on and more correct -- what this verdict scores is whether
the claims rest on counts, so the table to profile is the one being claimed
about. The fallbacks descend in how much they know: a table the snapshot
DESCRIBES before one it merely lists, since a zero-column entry is exactly the
view that caused this, and undefined leaves the notice its generic wording
rather than naming one wrongly.

The evals could not have caught this. Every test on this surface reports on
`engineering`, which is also the fixture's first table, so the wrong rule and
the right one produced the same name. The new test reports on a later table,
which is what makes the two candidates distinguishable.

* fix(agent): tell a wrongly-qualified profile call what to change

Read off the wire. `qwen3:8b` lost `database-assessment` five times out of five,
and the ledger said only that no table had been profiled. What it actually did
was call

  profile_table {schema: "ctx_3b6ba24865a80f993576ee1f566c5238",
                 table: "current_dept_emp"}

putting the snapshot fingerprint in the schema field -- a fair guess from a model
whose surrounding rules all speak of fingerprints and whose schema field was
described nowhere. The table was in the inventory. The qualifier was not.

It was answered "That table is not in the schema inventory this run captured.
Call inspect_schema for it first", which is untrue of the table and names a tool
a held run has just been narrowed out of holding. It repeated the identical call
three times, because nothing in the sentence identified anything to change, and
then spent the rest of its budget failing to report.

Three changes, none of them to what gets profiled:

- the refusal splits in two. A table that is absent keeps the old sentence; a
  table that is present under a name the caller qualified wrongly is told so and
  given the name to use, and is not sent to a tool it may not hold.
- the schema field describes itself, leading with "omit this unless the inventory
  qualifies the table", since omitting it is the right answer whenever the
  inventory names a table bare.
- resolution is untouched. #345 is why a named schema is never matched against a
  bare entry, and that protection still holds: nothing is profiled, the answer is
  still a refusal. Only the sentence changed.

Measured on the cell it was found in: qwen3:8b database-assessment, 0/5 before,
5/5 after.

* fix(agent): name the failing field, and ask a planless run for a plan

Two refusals that a model could not act on, both found by reading the wire
rather than the ledger.

The first is the largest measured shortfall on this project. `no-report` blocks
28 of the cells that are not locked, 18 of them losing to nothing else, and the
recording shows what it is from the model's side: `compose_report` refused with
"The arguments did not match the shape this tool declares", called again
unchanged, refused with the same sentence, for THIRTY-SEVEN turns until the run
ended having reported nothing. A `granite4.1:3b` data-analysis run spent 14 of
its 42 turns the same way. The sentence is true and unusable -- it names no
field, so there is nothing in it to correct. Zod knew the path and what was
expected there and this layer discarded it; it now crosses over, including
through `invalidEvidenceInput`, which is the one `compose_report` uses and which
took no detail at all. What is passed is server-authored, the path and the
expected type, never the value the model sent -- the rule `composedSqlOutcome`
states, for the reason it states it.

The second is the largest shortfall that had no sentence at all.
`no-plan-comparison` blocks 9 cells, 5 of them losing to nothing else, and
`shortfallNotice` returned null for it. The ledgers say what is missing is
knowledge, not diligence: `qwen3:4b` loses this cell 4 times in 5, and each
losing run holds four events -- started, context captured, report composed,
finished -- with no tool called at all. `compareBeforeReportNotice` cannot cover
that, because it names the two plan ids to compare and so can only speak once a
run HOLDS two. This one is gated on holding none and asks for the reading
instead.

An eval asserted the opposite of that gate -- that a planless run is not asked,
"because the nudge would be a lecture". That was judged without the measurement
and the measurement overturned it; the test now carries the numbers that did.

* fix(agent): accept a refusal whose reason begins on the next line

Plan mode's deliverable is one runnable statement or an explicit `NO STATEMENT:`
naming what the inventory lacks. Four losing runs across three models --
`qwen3:4b`, `qwen3.5:4b`, `nemotron3:33b` -- end like this:

    NO STATEMENT:
    The provided schema inventory includes the complete answer to the question,
    so a runnable SQL query is not needed. The question that would allow writing
    such a statement is: "..."

The marker line ends where it ends and the explanation begins on the next one.
Every one of those runs did exactly what the mode asks -- refused, said what was
missing, asked the one question -- and every one was scored `no-statement`,
because the reader only ever looked at the remainder of the marker's own line and
found it empty.

The #396 rule that empty check exists for is untouched: a marker with nothing
after it ANYWHERE is still `absent`, since the convention exists so the run says
what it lacks and a token that says nothing is not the ending the mode is for.
What moved is where "after it" is allowed to be. A fence does not answer for it
either -- prose is what is being waited for, and a run that followed its marker
with a code block still has not said what is missing.

`no-statement` blocks four cells, three of them at 4/5.

* test(ci): count the install script's backoffs instead of timing them

"does not sleep after the final attempt" checked its claim with a stopwatch:
two attempts with a 1s backoff had to finish under 1900ms, on the reasoning that
a second backoff would push the run past 2000ms. That measures the machine as
much as the script, and it failed at 1907.7ms on a laptop that was busy running
something else -- with nothing wrong in the script or the test's premise.

PATH is already replaced rather than prepended so the stub `bun` is the only one
in reach, which means a stub `sleep` beside it also wins. The test now reads the
waits the script actually asked for off that stub and asserts exactly one, of one
second. Same claim, counted rather than timed: exact, instant, and independent of
load.

* fix(agent): stop refusals from naming a tool the run cannot call

Two refusals told a run to do something it had no way of doing. Both are the
class of defect that has the best measured return on this project: three cells
went from 0/5 to 5/5 yesterday by making a refusal say what to change.

The deadline pair ended "Stop calling tools and finish with what has already been
established" and "Ask for something cheaper, or finish now." Finishing IS a tool
call. `compose_report` is ledger-only -- it reaches no database and is
short-circuited before the deadline gate -- so it still works for a run with
nothing left in its budget. A model that reads those sentences literally answers
in prose, and prose is scored `no-report`, which blocks 28 of the cells that do
not lock. Both now name that call and say why it works.

`TABLE_NOT_INVENTORIED` said "Call inspect_schema for it first". The comment one
line below it, written for the sibling refusal yesterday, already states why that
is wrong: the `no-table-profile` hold narrows a run to `profile_table` and
`compose_report`, so `inspect_schema` is gone. The measured sequence was two dead
refusals in a row -- held and narrowed, asked for a profile, names a table
slightly wrong, told to call a tool it no longer holds, calls it, told there is no
such tool. The inventory is in hand at the refusal site, so it now offers the
first few names from it instead of a tool. Six at most: a refusal that turns into
a catalog dump is a wall of its own.

Neither is gated on whether the run was narrowed. Naming real tables is the more
useful answer either way, and a run out of time should be told how to finish
whatever put it there.

* fix(agent): sample tool turns deterministically

This loop never set a temperature, so every run measured so far inherited
Ollama's defaults: temperature 0.8, top_p 0.9.

That is the wrong setting for what the harness measures. A cell counts as
working only at 5/5 consecutive passes, which makes the bar a variance test as
much as a capability one, and 26 of the cells that do not lock sit at 4/5 -- one
run away. A twelve-step tool chain sampled at 0.8 is a large and entirely
avoidable source of exactly that flapping. Choosing a tool and filling in its
arguments is a structural task; there is nothing a sample is meant to explore,
and a report wants the most likely sentence rather than a surprising one.

Found while checking a different hypothesis. Two independent research passes
recommended replacing the artifact UUIDs a model must copy between calls with
short handles, citing Anthropic's tool guidance and RULER's finding that UUID
needles degrade worst of any needle type. Probed directly before building it:
`qwen3:4b` returned both ids exactly, 10 times out of 10, with UUIDs and with
short handles alike. The published degradation is a long-context, many-id
effect; `compare_plans` passes two. So the large structural change was not
warranted and this one-line default -- named in the same research and easy to
miss -- was.

Asserted on the request body rather than on an outcome, because a default this
loop never states is a default nobody notices.

* feat(agent): judge a rewrite in one call, because the chain is what fails

`query-optimization` locks on 5 of 25 measured models. Every other surface is at
10-13. It is also the longest chain -- `inspect_plan`, `inspect_plan` on a
rewrite, `compare_plans` naming BOTH artifact ids, then a report citing them --
and the only call in this layer that needs two server-minted ids at once.

The lock rate tracks chain length rather than subject matter, and the numbers say
so twice. Across the ten weakest models the only surface any of them locks is
`planning`, which needs no tool call at all: 5 of 10 there, 0 or 1 everywhere
else. Across all 25, `operations` leads -- and it leads because the server does
the reading and the model authors no SQL.

So `propose_rewrite` reaches the same bar by a shorter road: two statements in,
and the server inspects both plans and records the comparison itself.

It is implemented as two recursive `handleCall`s, which is the whole design. Every
guarantee a manual `inspect_plan` carries applies unchanged -- drafted statement,
derived step id, audited operation, deadline admission, duplicate protection,
repair budget -- because it IS a manual `inspect_plan`, twice, with the server
supplying the sequencing. It declares no operationId of its own for the same
reason, and the test that counts operation ids is what would catch it growing one.
The comparison is the existing `comparePlans`, so the `plan-comparison` event the
verifier reads has one definition, not two.

Both plan readings' own words are handed back with the comparison. A first
version swallowed them -- the server had the ids, so why show them? -- and that
trades one shortfall for another: the report still has to cite, so a run whose
plans exist but whose ids it never saw can compare and then not report. What this
removes is the PAIRING, not the citing.

`inspect_plan` and `compare_plans` stay offered, so a run that wants to walk the
steps still may. The workflow rules name the one-call route first, following the
`operations` precedent of leading with the instrument. Narrowing keeps it, and
there it matters most: a narrowed run has lost `inspect_plan`, so the other two
tools both need a plan artifact it can no longer obtain.

* fix(agent): declare the smaller tool set to a prompted run too

A prompted run cannot emit `tool_calls`, so its tools are declared as prose: one
contract message, built once from the full selection and pushed before the loop.
When narrowing fires, the native path re-declares for free -- `takeTurn` is handed
`narrowedTools(record)` and the SDK simply stops offering what the run may no
longer call. The prompted path passed `undefined` and left the original contract
standing, while `handleCall` enforced the narrowed set regardless of protocol.

So a narrowed prompted run read a contract listing `run_read_query`, called it,
and was answered "There is no tool called run_read_query in this run." Every
`deepseek-r1` distill takes this path; between the four of them they lock 2 cells
out of 24.

The contract is now re-declared once, at the turn narrowing first applies, saying
plainly that the set has been reduced. `promptedToolContract` answers null for an
empty set, so a run with nothing left to declare says nothing.

The test is the other half of this, and it is the first test in the repository to
drive the prompted path at all -- which is why no test ever caught this. It needed
two things the harness did not have: `startRun` now takes `toolProtocol`, and the
scripted turns speak the protocol under test (a JSON object in ordinary text; a
native `tool_calls` reply leaves the SDK waiting for a tool result this path never
writes). Narrowing is tripped by the report reminder rather than the twelve-call
ceiling -- the same `narrowed = true`, two turns sooner.

* fix(agent): read the calls these models actually write

Two recoveries, both taken from real losing ledgers, both on turns a working run
never produces.

A `deepseek-r1:8b` assessment closed with 498 characters of prose holding six `{`
and five `}`: two well-formed claims, each citing an artifact id from one of its
own `profile_table` calls, and the outermost brace missing because the reply was
cut off. `objectsIn` only emitted a candidate when depth returned to zero, so both
readers saw nothing and the run scored `no-report`. The work was done and one byte
was missing. A truncated trailing object is now offered as a last candidate, after
every well-formed one, so a complete reply is read exactly as before. Closing what
the model left open changes no value: the result still has to parse and still has
to satisfy a tool's schema, so a truncation that lost a required field is refused
as it was. It cannot invent a field.

`deepseek-r1:7b` -- the only model in the fleet with no agent cell at any score --
writes its calls inside an envelope of its own invention:

    {"actions": {"inspect_schema": {"arguments": {"kind": "columns", …}, "type": "object"}}}

The intended call is legible twice over: the KEY names a tool the run holds, and
the nested `arguments` fits that tool's schema. The outermost object fits nothing,
so nothing was recovered and the run ended having called no tool at all. Envelopes
are now read by the tool NAME inside them rather than by a list of blessed shapes,
because the shapes are whatever a model improvises while the name has to be right
either way. The reader's existing rule holds: exactly one held tool named, or
nothing -- an envelope is a weaker signal than a schema match, so it may not be
where ambiguity gets resolved by guessing.

`readPromptedAction` also stops being gated on the prompted protocol. A native
model that narrates `{"action": …}` instead of calling was never read, while
`readPromptedPayload` on the very next line has always served both. There was no
reason for the two readers to disagree about which runs they serve.

Recovery reads intent and grants nothing: every recovered call goes through
`AGENT_TOOL_DEFINITIONS` and the audited pipeline, so a report citing an artifact
this run never produced is refused by the citation contract like any other.

* fix(agent): tell a run that read nothing what this workflow is scored on

`citeWhatYouReadNotice` fires only when the run HOLDS a usable artifact, because a
run with nothing to cite would be asked for the impossible. That guard is right,
and it left a hole: a run that took NO reading and reported off the captured
inventory holds nothing, hears nothing, and dies of `no-reading` never having been
told the bar exists.

It is worth an entire cell. `lfm2:24b` loses `operations` five times out of five,
and all five ledgers are four events long -- started, context captured, report
composed, finished -- in five to six seconds. It answers "eight tables with no
associated indexes" from the inventory it was handed and never calls
`inspect_operations`. It reads competently on other surfaces at 24B, so this is
not capacity: it is a run answering on turn one because nothing said the answer
had to rest on a reading.

The two sentences now divide by what the run holds. Artifacts plus the wrong
citation keeps the existing notice, which can name the ids it actually has. No
artifacts at all is sent to the reading tool instead. Saying "this run has taken
no reading" to a run that took six would be the false self-description this file
keeps catching, and three evals caught exactly that within a minute of the first
version.

NOT narrowed, and that is load-bearing: `AGENT_NARROWED_EXTRA_TOOLS.operations` is
empty, so narrowing here would remove `inspect_operations` -- the one tool the
sentence asks for. The same trap `no-plan-evidence` documents one branch below.

An eval asserted the old behaviour -- that such a run is told nothing at all. Half
of that reasoning survives and is why this notice still stays silent for a run
that has ids: it cannot be asked to CITE. It can be asked to READ.

* Revert "feat(agent): judge a rewrite in one call, because the chain is what fails"

This reverts commit c2ad675. It won nothing and it cost a locked cell.

Measured both ways. On the cell it was written for -- `qwen3:8b`
query-optimization, the surface that locks on 5 of 25 models -- five runs after it
landed produced 1/5, and the ledgers show the model never called `propose_rewrite`
once: every run walked the old `inspect_schema`/`inspect_plan` road anyway. And the
watch set over the four models that lock all six surfaces found `gemma4:26b`
query-optimization at 3/5, down from 5/5, failing `no-plan-comparison` and
`no-report`.

The likeliest cause is the part that touches a passing run: this workflow's rules
gained a sentence and were reordered to name the one-call route first, so every
optimization run's prompt changed. The tool itself only fires when called, and it
never was.

The reasoning behind it still looks right -- chain length tracks the lock rate,
and `operations` leads because the server does the reading. What is now measured
is that offering a shorter road does not make a model take it, and that paying for
the offer in the rules of a surface where four models already lock is the wrong
trade. If this returns it should arrive without touching the rules of a working
surface, and be measured on a cell that is already lost.

The prompted-protocol test from a later commit is kept: it lands in the same
describe block and has nothing to do with this.

* fix(agent): stop double-prefixing a statement the model already EXPLAINed

Measured across 133 optimization runs: 21 `inspect_plan` calls arrived with the
statement already prefixed, 12 were refused, and at least three runs lost
outright to it. `qwen3:8b` does it in 9 of its 15 runs on that surface.

`composeEstimatingExplain` prepends the prefix itself, so
`inspect_plan({sql: "EXPLAIN SELECT …"})` sent
`EXPLAIN QUERY PLAN EXPLAIN SELECT …` and the model was handed one line of
SQLite: `near "EXPLAIN": syntax error`. That is unactionable when the model did
not write the outer EXPLAIN, and the shared rules then tell it not to send the
same statement again. One run tried `EXPLAIN QUERY PLAN SELECT …` -- the only
repair reachable from that message -- and got the same error. Three abandoned the
tool and reported nothing.

Stripped rather than refused: the intent is unambiguous, the model asked for the
plan of a statement and named the operation twice, and this function already
trims whitespace without asking. A refusal would teach and still cost the run the
turn it has already spent.

`EXPLAIN ANALYZE` is deliberately not matched. It asks to EXECUTE, which is a
different request and one this run may not make -- removing it would quietly turn
a refused execution into an accepted estimate. Composing it through is what puts
the word in front of the guard that refuses it.

Found by an audit of every optimization ledger, which also found the larger thing
this does not fix: the workflow's first rule orders `inspect_schema kind="indexes"`,
the sample database has no secondary indexes, and the zero-row read is answered
"There is no object of that name in this database" -- false, and the opening turn
of 79 of 133 runs. That is next, and it needs its own measurement because it is a
refusal text a locked cell can see.

* feat(agent): decide sampling per model, not once for all twenty-five

For days this repository looked for one setting that suits 25 local models on six
surfaces, and every landing won some cells and cost others. Sampling is the
clearest case and the reason this module exists.

The loop set no temperature, so every run inherited Ollama's 0.8. Pinning it to 0
won five cells outright -- a cell locks only at 5/5 consecutive passes, so the bar
is a variance test as much as a capability one. It also cost `qwen3:8b` its
`query-optimization` cell, 3/5 down to 0/5. Reading all fifteen of its ledgers on
that surface: at 0.8 it opened with `inspect_plan` on 3 of 5 runs and answered all
three; at 0 it opened with `inspect_schema` on 10 of 10 and lost every one. The
opening move decides the run -- `inspect_plan` first is 3/3 answered,
`inspect_schema` first is 1/12 -- and determinism pinned it to the losing branch
instead of letting it wander into the winning one.

Both measurements are true. No global number holds both.

`src/lib/agent/models/` is where a model's own settings live, and the rule that
keeps it from becoming a pile of invented constants is in the module header: the
default is the whole policy, a profile may only contradict it where a measurement
required it, and every override carries the numbers that bought it. `measured` is
not documentation but the admission ticket -- a test refuses a profile that
arrives without one. A model with no profile is not unsupported; it gets the
default, which is what every locked cell was measured on.

An override's scope is as narrow as its evidence. `qwen3:8b` locks its other five
surfaces deterministically, so its exception is keyed to the one cell that needs
it rather than to the model. Keys are lower-cased because the same weights answer
to more than one spelling and an exactly-matched key would stop applying the day a
tag changed.

* refactor(agent): one file per model, created when a model earns one

The register moves to `models/index.ts`, the contract to `models/profile.ts`, and
`qwen3:8b`'s settings to `models/qwen3-8b.ts` beside them. A model's own file is
where its own measurements and its own overrides live.

The contract is its own module rather than part of the barrel so a profile can
import the shape without importing the register that lists it -- otherwise every
profile would depend on every other one through the barrel, and adding a model
would be a change to all of them.

There are deliberately not 25 files. A file is created when a measurement shows
the default is wrong for that model. Twenty-four files holding nothing measured
would not be tidiness: they would be an invitation to fill them with plausible
constants nobody can justify later, and the whole value of the directory is that
every line in it answers to a number. A model with no file is not unsupported --
it gets the default, which is what every locked cell was measured on.

`qwen3-8b.ts` also records what its own override is working around rather than
fixing: the workflow's first rule orders an `inspect_schema kind="indexes"` read,
the sample database declares no secondary index, and the zero-row answer is
"There is no object of that name in this database" -- false, unlogged, and the
opening turn of 79 of 133 optimization runs. When that refusal tells the truth the
losing branch stops being a trap, and this profile should be re-measured at the
default before it is kept. That note belongs in the file the next reader will open,
not in a backlog.

* fix(agent): an empty index inventory is an answer, not a missing object

`CATALOG_MATCHED_NOTHING` told every empty catalog read the same thing: "There is
no object of that name in this database." For a column read that is right and the
eval above it says why. For an index or relation read it is false -- the objects
were found and declare none, which is an ANSWER to an optimization question rather
than a failed lookup.

It is the opening turn of most optimization runs. That workflow's first rule
ORDERS an `inspect_schema kind="indexes"` read; the sample database declares no
`CREATE INDEX` anywhere, so the call returns zero rows every time. 79 of …
cevheri added a commit that referenced this pull request Aug 24, 2026
…rator-supplied layer (#468)

* feat(agent): drive a model that cannot emit tool calls by asking in prose

The capability gate had two answers — admit, or refuse — and it was refusing a
class of model that can do the work. `deepseek-r1` at 7b, 8b, 14b and 32b streams
fine and reasons well; it simply never emits a `tool_calls` field. The probe
established that honestly, and the gate turned it into "configure a different
model", which is the wrong conclusion: asked for a tool call in prose, those same
models produced the correct action **15 times out of 15**.

So the gate grows a third verdict rather than a second refusal. A run it admits
this way carries `toolProtocol: "prompted"` on its ledger header, and the loop
reads that to decide how to ask.

## Which refusals qualify, and why it reads `disproved`

Exactly one shortfall, and it must have been WATCHED rather than merely
unobserved: the model was handed a tool and answered without calling it.

The condition reads `refusal.disproved`, not `refusal.missing`, and that
distinction is the whole judgement. `missing` is what the run needed and did not
get — so a model that called no tool is *also* missing schema-valid arguments,
because there were no arguments to validate. `disproved` is the half the probe
watched fail, and it already excludes `structuredOutput` when tool calling was
never established. A first version keyed on `missing.length === 1` and rejected
precisely the models this exists for: every `deepseek-r1` size arrives with two
missing and one disproved.

Everything else still refuses. An endpoint that never streamed cannot be driven
in prose either — prose needs the same stream. Arguments watched to fail schema
validation are not fixed by changing how the call is requested.

## The protocol

`prompted-tools.ts` states the contract in prose, describes each tool the run
actually holds with the JSON Schema its arguments are declared against
(`z.toJSONSchema` off the same Zod schema the native path enforces, so the two
cannot drift), and parses the reply.

Three details are measured rather than guessed:

**The contract goes in a USER message, not the instructions.** DeepSeek's own
model card says to avoid system prompts. Moving it took the run from 0 tool calls
to 1 — the single change that made the path work at all.

**The parser scans by brace depth, tracking string literals**, because a regex
truncates nested arguments at the first `}`.

**The LAST object in the reply wins.** A reasoning model rehearses candidate
calls inside its thinking before committing to one, so the first object it writes
is usually not the one it means.

## Why the result is a user message too

A prompted call gets a `role: "user"` result rather than `role: "tool"`. There is
no tool-call id to answer: the assistant never declared one, and a real endpoint
rejects a tool result whose id it has not seen with a 400.

## The native path is untouched

`toolProtocol` is optional on the record and absent folds to `native`, so every
existing ledger keeps its meaning without a migration. A run whose model calls
tools takes exactly the code path it took before — the prompted branches are
behind one boolean read off the record. The positive verdict is still cached; the
prompted admission deliberately is NOT, because a model that starts calling tools
should be asked natively again rather than held to a decision made once.

Measured across six agent surfaces per model: on the prompted path `deepseek-r1`
answers Investigate and Operate at 8b, and Assess at 32b — surfaces that
previously could not open a run at all.

* fix(agent): let a run reach the bar its own workflow is judged against

Measured across 25 local Ollama models on all six agent surfaces — five workflows plus
toolless planning, 150 runs in one uniform pass. 78 cells answered. No model passed all
six, and the 66 failures are not spread evenly: `no-report` is 37 of them and appears on
every surface, while `no-plan-comparison` (7) and `no-table-profile` (4) concentrate on
the two workflows whose verdicts ask for a specific artifact.

Reading those ledgers, the runs were not refusing to do the work. They were finishing
one call short of a bar nobody had told them about, or wandering until their budget ran
out. Three mechanisms, from the same evidence.

## The bars are STATED, which is the #350/#356 rule this repository already keeps

`WORKFLOW_TOOL_RULES.operations` carries the shape and the comment: "the verifier's rule,
in the model's own terms … a rule the model is never told is a rule live runs fail". Two
workflows were missing it.

`database-assessment` said "profile the tables that matter", which reads as advice about
WHERE to look rather than the condition its report is judged by. 18 of 25 models failed
that surface, five of them composing a report having called no tool at all.

The wording NAMES THE TOOL, and that was measured rather than chosen. A first version
said "profile at least one table": `qwen3.5:4b` satisfied those words by composing
eighteen count statements by hand with `run_read_query`, and three more models went to
`inspect_schema`. All four had done what the sentence asked and none produced the
`table-profiled` event the verifier reads. Naming `profile_table` moved
`granite4.1:30b`, `granite4.1:8b`, `qwen3:4b` and `qwen3:8b` from failing to answered on
that surface. The second clause exists because a model composing counts itself is being
diligent, not lazy, and deserves to be told why that does not clear the bar.

`query-optimization` described both its instruments and never said the report is judged
on having used one. Its 6 `no-plan-comparison` ledgers have the arc this repository's own
gate test scripts, character for character: one `inspect_plan`, then a report.
`nemotron-3.5-lightning:30b` and `qwen3.5:9b` produced identical ones — a correct
diagnosis, and no `recommend_change` call at all. Stopping after the diagnosis is a fair
reading of "why is it slow" when nothing says otherwise, so now something does.

## A run that will not record what it read is narrowed to what would finish it

`AGENT_REPORT_REMINDER_NOTICE` already tells a silent run to report, and the ledgers show
why a notice is not enough: reminded and still holding everything, those runs went back
to reading, repeated a refused selector, or wrote strategy about varying one. So the
reminded turn narrows the choice instead of repeating the instruction, and a second
trigger covers the half a reminder cannot reach — a model reading itself out of budget
never stops talking, so it never earns a reminder. `AGENT_UNREPORTED_CALL_CEILING` is 12,
read off the distribution: across 43 answered runs the most tool calls any made was SIX,
while the runs that looped ran to 7, 9, 11, 20 and 57.

WHAT it narrows to is the part this measurement corrected. An earlier version left
`compose_report` alone, and on two surfaces that makes the run's own bar unreachable — an
assessment cannot produce `table-profiled` and an optimization cannot compare plans — so
it would trade `no-report` for a different failure, not a smaller one.
`AGENT_NARROWED_EXTRA_TOOLS` keeps whatever the verdict requires, which turns the
mechanism from a muzzle into a funnel: a narrowed run can only do the things it is judged
on.

Declaration and dispatch are read from one place, because a review caught the half that
was missing: narrowing only what the model is TOLD about left `handleCall` reading the
full selection, so a tool remembered from an earlier turn was still executed, and a live
run reached 25 calls after the ceiling fired at 12.

## And a run one call short is asked, once, at the moment it can still act

`compose_report` ends the run, so these are delivered INSTEAD of that call and the call is
not executed — the same shape as `AGENT_PRESENT_BEFORE_REPORT_NOTICE`. Holding two plans,
the run is given both artifact ids and asked to compare them; the stated rule had already
moved models to three and five plans without one of them making the call, so the ids are
named rather than described. Holding ONE plan it is asked for the second, or for an index
recommendation on the plan it has — never for a comparison, which it cannot make. Telling
a model to do the impossible is the mistake the reverted `ANSWER_NOT_PRESENTED` refusal
already paid for: the run then neither answers nor reports.

Both notices stay out of the way after one delivery, and the gate eval asserts that
directly: a model that ignores the nudge still reports, and the verdict is still the
honest `no-plan-comparison`. A nudge that could swallow a report would be trading that
verdict for `no-report`, which is worse.

## What is claimed, and what is not

Claimed: each mechanism fires where it was aimed, and the four Assess recoveries above
were measured on the same question and connection before and after.

NOT claimed: a score for any model. These models vary more between runs than these
changes move them — the same model on the same code produced 20 tool calls in one round
and 2 in the next, and a cell that passed in one sweep failed in the next. A per-model
claim needs repeats, and one cell measured properly is worth more than twenty measured
once.

11 new tests. 132 isolated, 161 evals, all 30 groups, lint, typecheck, knip and build
clean.

* fix(agent): tell a run that read the engine and cited none of it

The bottom of the measured table, which the previous round did not touch. Of 25 local
models on six surfaces, the ones scoring 0/6 to 3/6 fail differently from the ones scoring
5/6: they are not stopping short of a tool, they are reporting on the wrong evidence.

The clearest case, from the ledger: `mistral-small3.2:24b` on `operations` called
`inspect_operations` SIX times, every call completed, and then composed a report whose only
citation was the schema inventory. `verifyOperationsGoal` asks for one `source: "artifact"`
reference and got none, so a run that had done the work scored `no-reading`. That cell had
passed before, which is what made it worth reading rather than filing under variance.

The same misdirection on the other five surfaces scores `empty-evidence`: the artifacts
cited came back with no rows while the run held ones that did. `qwen3.5:4b` and
`mistral-small3.2:24b` both landed there. One mistake, two shortfall names.

So the run is told, once, at the only moment it can still act — `compose_report` ends the
run — and the ids are NAMED rather than described, because the run already holds them and
looking them up is not the part it is getting wrong.

## Read from the CALL, not from the ledger

The report has not been recorded yet; that is the whole point of intercepting the call
rather than scoring the run afterwards. `citedArtifactIds` reads the arguments permissively
because `composeReportTool` validates them properly a moment later — all this has to decide
is whether the run is about to cite nothing it read.

## The distinction two integrity evals had to teach me

A first version filtered the cited ids down to the ones the run actually holds, and then
treated "cited nothing that resolves" as "cited nothing". That is wrong, and it is wrong in
the direction that matters: an id the run never produced is a DIFFERENT mistake, and
`composeReportTool` answers it with a precise refusal naming the invented id. Filtering
first put this notice in front of that refusal and swallowed it.

Two evals caught it — including the one where the thing being cited is a hostile row value
from a read result, which is exactly the case where the citation contract has to be the
voice that answers. So whenever a citation is present but unresolvable, this stays silent
and lets the tool speak. Only a call citing no artifact at all, or citing only artifacts
that came back empty while better ones are held, is held back.

## The #350 guard, again

It fires only when the run HOLDS something worth citing. A run that read nothing is told
nothing: asking it to cite a reading it never took is asking for the impossible, which is
the mistake the reverted `ANSWER_NOT_PRESENTED` refusal made and which cost those runs
their report as well as their answer. And a run whose every reading came back empty is
still offered one, because an empty reading IS an answer on some surfaces — the verifier
only rejects a report resting ENTIRELY on empties.

Offered once, then out of the way, and an eval asserts that directly: a model that ignores
the notice still reports, and the verdict is still the honest `no-reading`. A notice that
could swallow a report would trade that verdict for `no-report`, which is worse.

3 new evals. 164 evals, 132 isolated, all 30 groups, lint, typecheck and knip clean.

* fix(agent): read a plan statement the model wrote without a fence

Measured on two local models given the same planning objective, and the two shapes have to
be told apart rather than handled together.

`qwen3:1.7b` produced this, verbatim:

    sqlite
    SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' …

    The statement retrieves the names of the tables in the database. …

It had followed the contract far enough to put the engine tag on its own line and the
statement under it. It left out the backticks, `readPlanStatement` found no fence, and the
run was scored `no-statement` — the plan-mode bar — for a formatting slip wrapped around a
real statement. Planning mode holds no tools, so this was the only thing that could fail.

`qwen3:0.6b`, same objective, produced the other shape:

    sqlite
    <sqlite>
    The tables are: current_dept_emp, department, dept_emp, …
    </sqlite>

That is prose, and it has to keep failing. `plan-statement-drafted` is recorded whenever
this reader returns a statement — the guard and identifier checks ride along on the event
rather than gating it — so a reader that accepted a sentence would score the run answered
while the user reads prose. Loosening the fence rule is only worth anything if it stays
closed to that.

## What separates them

A leading query verb, checked against the START of the candidate rather than searched for
anywhere in it: a sentence ABOUT a select is not a select. `SELECT|WITH|EXPLAIN|SHOW|
PRAGMA|DESCRIBE|DESC` — the read-shaped openers a plan deliverable can have. Nothing a
guard would reject as a write is in the list, because a plan run's statement is a read by
contract and this reader should not be where a write first becomes plausible.

The candidate ends at the first BLANK line, because that is exactly where these models put
the explanation they were also asked for, and carrying prose into the SQL would hand the
editor something no engine accepts.

## The precedence that a test had to defend

The unfenced reading happens LAST — after the fenced block and after the refusal — so
every existing precedence is untouched: the first query-tagged fence still wins among
several, and a refusal still wins over an illustration the run declined to stand behind.

And it reads only the lines that were never inside a fence. A first version re-scanned the
whole text and broke the deliberate rejection of a fence tagged for another engine: the
`mysql` block a PostgreSQL run must not take was rejected as a block, and then its SELECT
was picked up as unfenced text and filed under this connection's dialect — the exact
mislabelling that rejection exists to prevent. The eval pinning it failed immediately,
which is the whole reason it is written that way.

No tag is returned, because the model named no fence and so told us nothing about the
engine; the recorder stamps the connection's own dialect, as it already does for an
untagged fence.

4 new tests. 48 plan-statement, 164 evals, 1089 agent unit tests, all 30 groups, lint,
typecheck and knip clean.

* fix(agent): ask the verifier what a report is missing before it lands

Seven fixes were measured across 25 local models today and all seven were one mistake
under different names. The model did the work and then missed a protocol detail on its
finishing move: wrote the report as prose, wrote SQL without a fence, cited the schema
inventory instead of the six readings it had taken, took one query plan instead of two,
analysed a table with `run_read_query` instead of `profile_table`. Each cost the run
everything it had established, because the finishing move gets exactly one attempt.

Five of those were answered with a hand-written notice apiece. That does not scale to the
shortfalls nobody has hand-written yet, and it drifts: the `database-assessment` bar this
file states in its own words was first phrased as an activity rather than a tool, and four
models satisfied the sentence while failing the check.

So the notice is DERIVED from the verifier instead. `VerifiableAgentRun` is a Pick over the
record, so the loop can assemble the run it is ABOUT to become — the ledger so far, plus
the report on its way in — hand that to `verifyRunGoal` unchanged, and read the shortfalls
this report WOULD earn. What the model is then told comes from the verifier's own
vocabulary, which cannot fall out of step with the check the way a duplicated sentence can.

## It changes no bar

A run still has to produce the profile, the comparison, the citation. All it gains is being
told what is missing while it can still act, instead of reading it on a verdict after the
run is over. `compose_report` ends the run, which is why the call is held back rather than
answered afterwards.

## Which shortfalls it speaks for

`no-table-profile` first, because it is the largest one with no purpose-written notice: 8 of
25 models failed `database-assessment` on it, four of them having called no tool at all. The
sentence names `profile_table` and a table from the run's own inventory, and says outright
that counts composed with `run_read_query` do not satisfy it — the wording measured to
matter, after `qwen3.5:4b` answered the earlier phrasing with eighteen hand-written count
statements.

`shortfallNotice` returns null for everything else, and that list is the #350 rule applied
case by case rather than a stub: `no-report` cannot arise here because a report is being
submitted, `cancelled` is not the model's to fix, and the shortfalls that already have a
purpose-written notice keep it, because those name artifact ids this generic sentence cannot.
New shortfalls plug in by adding a sentence, not a mechanism.

## Offered once

Three evals pin the contract: a run told what is missing goes on to clear its own bar; a run
that ignores the notice still reports and still earns the honest `no-table-profile`; and a
report that already meets its bar is not delayed by a turn. The two gate evals for this
workflow gained a second report turn for the same reason — the model declining the offer is
now part of what they assert, which makes them stronger than they were.

3 new evals, 2 extended. 167 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): ask for the plan an index recommendation rests on

The second shortfall to go through the verifier-derived notice, and the reason that
mechanism was worth building: this cost a sentence rather than a design.

Measured on `qwen3:8b` and `qwen3:14b`, whose Optimize ledgers are the same arc both times:
`inspect_schema`, a refused read, then `recommend_change` for an index — and no
`inspect_plan` anywhere. `verifyQueryOptimizationGoal` accepts an index recommendation only
when its evidence cites a plan the run inspected, so both scored `no-plan-evidence` having
produced a recommendation that may well be correct and simply is not established.

There is no plan id to name, because the run holds none. So the sentence asks for the
reading rather than for a citation the run cannot make: inspect the plan, recommend again
citing it, then report. That is the #350 rule in the small — every notice here has to be
answerable by the run receiving it.

The two gate evals for the index route gained a second report turn, for the same reason the
assessment gates did: the model declining the offer is now part of what they assert, and the
bar still closes on it.

168 evals, all 30 groups, lint, typecheck and knip clean.

* feat(agent): record the calls the server turns back

A held call was invisible. The drive can refuse to RUN a `compose_report` and answer the
model with a notice instead — cite a reading you took, compare the two plans you hold,
profile a table before you report — and because a call that is not run settles no step,
none of that reached the ledger. A reader saw a run that reported once, when what happened
was that it tried, was asked for something, and tried again.

That is a hole in the thing this ledger exists to be, and it is worst exactly where it
matters most: on the runs where the model DECLINES the offer, the entry this commit adds is
the only place the offer exists at all.

It also cost real time. `71dfe06` was measured on `qwen3.5:9b` and `qwen3:8b` and moved
neither — three runs each, all still `no-table-profile`. Whether the notice fired and was
ignored, or never fired, is the whole difference between a model limit and a bug in the
condition, and the ledger could not tell them apart because neither leaves a trace. This
makes that question answerable from the record rather than by adding logging to a build.

## The event

`call-held` carries the tool, the sentence the server said, and — where the notice came
from the verdict preview — the verifier's own name for what was missing. `shortfall` is
optional because the purpose-written notices answer conditions the verifier has no
vocabulary for: a run holding two plans and citing neither is not a shortfall, it is one
call short of one.

It is a NARRATIVE event, beside `plan-comparison` and `recommendation`, and for their
reason: nothing was executed and no step was settled. The run is telling the ledger a
decision it made, not recording an effect.

All four hold sites record through one helper, so a fifth notice cannot be added silently.

The timeline renders it under the existing `refused` tone rather than a new one — "the
server did not run this" is what happened, and a second colour for one fact would be a
second vocabulary to keep in step.

The compiler found every place this had to be declared: the store's exhaustive kind record,
the narrative union, the timeline's switch, and the contract fixture in `types.test.ts` —
which is typed as a total record over the union precisely so a new kind cannot be added
without one.

168 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): recover a tool call the model wrote as its payload

The largest measured loss, and the shape it actually has.

`no-report` was 37 of 66 failing cells across 25 local models on six surfaces — the only
shortfall appearing on every surface. Reading 36 of those ledgers, SEVEN had written a
complete `compose_report` payload into their closing prose, fenced as JSON, instead of
calling the tool. Seven different families: `deepseek-r1:14b`, `deepseek-r1:32b`,
`granite4.1:30b`, `lfm2:24b`, `nemotron3:33b`, `qwen3:0.6b`, `qwen3:1.7b`. `granite4.1:3b`
did it too and appended "(The compose_report tool was successfully used with the required
structure, producing the above report.)" — it believed it had called the tool.

Every one of those runs had done the work. The reading was taken, the claim was written,
the evidence was cited. Only the channel was wrong, and the run scored as having
established nothing.

## Identified by schema, because nothing else can name the tool

`readPromptedAction` cannot help: it looks for an `{"action", "arguments"}` envelope and
these models wrote the bare arguments. So `readPromptedPayload` matches a candidate object
against the input schema of each tool the run HOLDS. That is the only way to know which
tool a bare payload belongs to without guessing at it.

Two refusals matter more than the recovery, and both are pinned by tests:

**Ambiguity is not resolved.** A payload satisfying two held tools recovers nothing.
Choosing between them would be the reader inventing an intent the model never expressed.

**Recovery grants no authority.** What comes back is a name and an unvalidated input, put
through the same `AGENT_TOOL_DEFINITIONS` schema and the same audited pipeline as a native
call. A recovered report citing an id this run never produced is refused by the citation
contract exactly as it would be. That is also what bounds the injection case: a hostile row
value the model has quoted back can be recovered as a call, and it then meets every check a
call meets — including the one that refuses evidence this run did not produce. Recovery
reads intent; it does not confer trust.

## Read on both protocols, and only when the model said nothing else

Those seven were NATIVE tool callers. They emit `tool_calls` for other tools and hand-write
this one, so restricting recovery to the prompted path would have missed the case it was
built for.

It runs last — after a native tool call, after the prompted envelope — and only when the
turn produced no call at all. A model speaking the protocol it was given is unaffected,
which is the property that has mattered most on this workload: these models are sensitive
enough to their prompt that changes for everyone have measurably moved models that were
already passing.

The LAST fitting object wins, for the reason the envelope reader also reads backwards: a
reasoning model rehearses payloads inside its thinking before committing to one.

7 new tests. 21 prompted-tools, 168 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): narrow a held run to the tools that would clear its verdict

The `call-held` entry answered its first question within minutes of existing, and the
answer changed what to do next.

`71dfe06` had been measured on three cells and moved none of them — `qwen3.5:9b`,
`qwen3:8b` and `qwen3.5:4b`, three runs each, still `no-table-profile`. Whether the notice
fired and was ignored or never fired at all is the difference between a model limit and a
broken condition, and nothing in the ledger could tell them apart. With holds recorded, the
same cells say it plainly:

  qwen3:8b     no-table-profile   holds=2   tools=[inspect_schema, inspect_schema]
  qwen3.5:9b   no-table-profile   holds=1   tools=[]
  qwen3.5:9b   OK                 holds=1   tools=[... profile_table x6]
  qwen3.5:4b   no-table-profile   holds=2   tools=[inspect_schema x4]
  qwen3.5:4b   no-table-profile   holds=1   tools=[inspect_schema x2, run_read_query x3]

The notice fires every time. `qwen3.5:9b` took the offer on one run and answered after six
`profile_table` calls, which is the mechanism working exactly as designed. On the runs that
failed, the model heard it and spent the turn somewhere else entirely.

So the notice was never the missing part. The wandering after it was — and this repository
already had the answer to wandering.

## Pairing two mechanisms it already had

A held call now also narrows the run, using the same `AGENT_NARROWED_EXTRA_TOOLS` the
unreported-call ceiling uses. That record is keyed on what each verdict ACCEPTS, so on
`database-assessment` the run is left exactly `profile_table` and `compose_report`: it can
fix the thing it was asked to fix, or say nothing. It cannot go back to `inspect_schema`.

## Only where the fix is inside that set

`ShortfallAdvice.narrow` carries that per shortfall, and an eval caught the case the moment
it was written: `no-plan-evidence` is answered by calling `inspect_plan`, a READING tool
that the optimization verdict does not accept. Narrowing there would have removed the very
tool the notice asks for — telling a run to do something and taking away its means of doing
it, which is a mistake this file has paid for twice already and now cannot make silently,
because the flag has to be decided for every sentence added.

1 new eval, 1 extended. 169 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): refuse a catalog read that matched no object at all

The root cause this effort named a day ago and then did not fix, while shipping a notice
that helped models route around it. That ordering was the mistake; this is the fix.

The repository's own `query-optimization` eval objective is "Why is the employee listing
query slow?" — and there is no `employee_listing` object in the sample, because "employee
listing" is the name of a QUERY. Models called `inspect_schema` for it, the composed
`sqlite_master` read matched nothing, and `readCatalog` returned that as a COMPLETED step
with `rowCount: 0` and a citable correlation id. The model then cited it — correctly, since
the server had called it a success — and `restsOnlyOnEmptyResults` scored the run
`empty-evidence`. Three families produced character-identical ledgers doing exactly this:
`gemma4:26b`, `mistral-small3.2:24b` and `lfm2:24b`.

The model was not wrong. The server answered a question about a non-existent object with
success, and the run paid for it.

`profile_table` has had the right behaviour all along — `TABLE_NOT_INVENTORIED` refuses a
table the inventory does not list — and this is the same refusal for the same reason, with
its own closed code so the sentence can say something useful: check the inventory, and note
that a name from the objective may be the name of a query or a report rather than of a table.

## What it deliberately does not refuse

An empty RESULT. `run_read_query` returning no rows has established something about the
data, and this workflow family has a whole eval file (`empty-result-detection`) about not
treating that as failure. The distinction is between a question about the DATA, which zero
rows answers, and a question about the CATALOG, which zero objects does not: nothing was
inspected, so there is nothing to cite. Both directions are asserted.

Grounding reads are exempt. A grounding read is the SERVER asking, and an empty answer there
is the honest state of a database with no tables — the drive reads that itself and says so in
its opening note rather than being refused by a check meant for the model's own calls.

## Why this ordering matters

An adversarial review of the last twelve commits put it plainly: a notice that tells the
model to cite a different artifact treats the symptom of a wrong server answer. Every fix in
this family should be checked against that test — is the run being helped to work around
something we got wrong, or is the wrong thing being fixed? Here it was the second, and it had
been sitting diagnosed in the log since this morning.

2 new evals. 171 evals, all 30 groups, lint, typecheck and knip clean.

* fix(agent): let an operator raise the per-turn ceiling that was killing local runs

The largest measured loss that is unambiguously OURS, and one that makes every other number
suspect while it stands.

Across 25 local Ollama models on six agent surfaces, NINE runs ended
`status=failed, stopReason=model-timeout`. The model was still working; the drive stopped
waiting. Six were counted as `no-report` — the shortfall this whole effort has been chasing —
and four were on Optimize, the surface reported as hardest. The clearest case:
`deepseek-r1:8b` in PLANNING mode, cut 92 seconds into its first turn with a zero-event
ledger. Planning holds no tools, so thinking time is the only thing that can fail there, and
the prompt it was thinking about had just been enlarged by this repository.

`AGENT_MODEL_TURN_TIMEOUT_MS = 90_000` is not wrong about what it was written for. Its
docblock says "turns on this workload land in seconds, so a ceiling this far above them is
only ever reached by a call that is not coming back", and the 300-second two-event run that
motivated it was a real defect. It is wrong about a reasoning model on consumer hardware,
which is the transport this product now supports.

So the value becomes configuration and **the default does not move**: an operator who sets
nothing measures exactly what every published number was measured at.

## Two guard rails the repository imposed, both worth having

**The env read belongs in `config.ts`.** `tests/unit/agent-documentation.test.ts` asserts that
exactly one module in the agent tree reads `process.env`, and it failed the moment the read
went in beside the constant. The same test then required the variable in `.env.example` and in
`docs/AGENT.md` — the right demand, since a knob an operator cannot find is a knob that does
not exist.

**`execution-policy.ts` must stay client-safe.** Putting the function there and importing
`config.ts` for the read broke `bun run build`: that module is reachable from the browser
(`Studio.tsx` -> `use-agent-prefill.ts`), and `config.ts` imports `fs/promises`, so the
attempt pulled Node's filesystem into the client bundle. The dependency has to point from the
server-only module to the client-safe one, which is why the whole function now lives in
`config.ts` and imports the budgets rather than the reverse.

## What a bad value does

Ignored — not clamped, not thrown on. A mistyped variable must not take down the start path,
and a ceiling of zero or NaN would end every turn instantly, which is worse than the value the
operator meant to set.

A good value is capped just under half the smallest workflow deadline. `AGENT_WORKFLOW_BUDGETS`
guarantees every workflow can take at least two turns — an invariant this file's own test
asserts — so a ceiling past that would configure a run that cannot finish.

## Why this one before anything else

An independent read of the sweep put it plainly: while a fixed 90-second cap stands, every
measurement is systematically biased against reasoning models and against whichever surface
carries the longest prompt. Fixing anything else first means measuring through a known
instrument fault.

5 new tests. 79 execution-policy, 128 documentation, 171 evals, all 30 groups, lint,
typecheck, knip and build clean.

* feat(agent): let the endpoint enforce the prompted protocol instead of asking for it

The prompted path asks a model that cannot emit `tool_calls` for a single JSON object and
then parses whatever prose comes back. That works — measured 15 times out of 15 on the
`deepseek-r1` family — and it is the wrong place to be doing the work. On that path a
malformed reply is a total loss: there is no `tool_calls` field to fall back on.

`no-report` is 51 of the 104 failing runs across 463 measured runs on 25 local models, more
than the other seven shortfalls combined, and it is overwhelmingly a FORMAT failure. This
repository has answered it twice by reconstructing intent after the fact — `readPromptedAction`
for the envelope, `readPromptedPayload` for a payload written as prose. Constrained decoding
is what gives that a priori, and it was never tried.

## Probed before it was written

Asked of Ollama's OpenAI-compatible endpoint directly, with the schema below as
`response_format: json_schema`:

    {"action": "inspect_schema", "arguments": {}}

Conforming, and conforming again with `tools` present in the request. So the constraint is
available on the transport this product actually runs on, which is the question that decides
whether any of this is worth writing.

## Prompted path only, and that is the whole risk argument

`takeTurn` gains an optional shape, and it is passed only where `prompted` is true — where
`tools` is already `undefined`, so there is no interaction with tool calling to reason about,
and where the alternative is parsing free prose. The native path is sent not one different
byte.

That matters more than usual right now: 40 cells across 16 models have just been locked at
five consecutive passes each, every one of them on the native path. A change that cannot
reach them cannot cost them.

## The constraint and the parser disagree on purpose

`PROMPTED_ACTION_SHAPE` requires `arguments`; `actionSchema` keeps it optional. A constraint
is a demand the server makes of a model that can satisfy it. A parser is a reading of
whatever actually arrived — including from an endpoint that ignored the constraint entirely,
which is exactly the case the prompted path exists for. Tightening the parser to match would
throw away the tolerance that made this path work at all.

## What is not claimed

That it works end to end. The probe establishes the endpoint honours the schema and the suite
establishes nothing else broke; no run has yet gone through the application with it. The four
`deepseek-r1` sizes are its only real examination, and they are measured next — their single-run
baseline is 2/6, 2/6, 2/6 and 1/6.

171 evals, 132 isolated, 21 prompted-tools, typecheck and lint clean.

* Revert "let the endpoint enforce the prompted protocol": measured, and it costs the run

Constrained decoding works and is too slow to use here. Both halves were measured rather
than argued.

`deepseek-r1:8b` on Investigate, three runs with the constraint, all identical:

    CALL inspect_schema        <- the constraint held: a valid action, first try
    call-held
    END deadline-exceeded      <- 461s, 455s, 454s

The protocol was never the problem after this change; the clock was. Each turn now costs so
much that the run reaches its 450-second deadline having made ONE tool call. Format was
guaranteed and the run was lost, which is a worse trade than the one it replaced.

## The one-variable check, because two things had changed

The turn ceiling had been raised from 90s to 150s in the same session, so "the constraint is
slow" was an attribution and not yet a measurement. Same cell, same raised ceiling, constraint
removed:

    95s  succeeded
    461s deadline-exceeded

One pass at 95 seconds against nothing under 454 with the constraint. The constraint is the
cost, and it is reverted.

## What stays, and what the second run says

`takeTurn` keeps the `constrainTo` parameter, passed `undefined`. The seam is right — the
endpoint does honour `response_format: json_schema`, probed directly — and the cost was in the
model, not the wiring. A future model that reasons less per token could take it.

The second measurement also says something this change is not responsible for: without the
constraint the cell is 1 of 2, not 2 of 2. `deepseek-r1:8b` is unstable here on its own
terms, and that belongs to the `no-report` work rather than to this.

171 evals, typecheck clean.

* fix(agent): ask twice before letting a doomed report through

The verdict preview holds a `compose_report` whose own verdict would reject
it, names the missing artifact, and narrows the run to the tools that would
produce it. It fired once per run, and five repeats on three models say once
is not enough: `qwen3:8b`, `qwen3:14b` and `qwen3.5:4b` each lose
`database-assessment` 5 times out of 5, and all fifteen ledgers have the same
shape -- notice fires, run is narrowed, model reports again anyway, run ends
`no-table-profile`.

The second ask is bounded at two holds. An earlier attempt to repeat an ask
sat on the silence path, which every run traverses, and eighteen tests failed
because every run gained turns; it was reverted. This one fires only where a
report is being submitted that its own verdict would reject -- a run already
losing -- so a run about to pass cannot reach it. A model that will never
comply pays one extra turn before the same verdict it was going to get.

The bound is two rather than three because three was measured, in the gate
evals, to cost a non-complying run three wasted turns for no behavioural gain.

* fix(agent): hold a report against the table it is making claims about

The notice that holds a `compose_report` for `no-table-profile` named
`inventory[0]` -- the first table in the captured snapshot. On the sample
database that is `current_dept_emp`, a view the catalog read describes with no
columns at all, while the report `qwen3:8b` was trying to submit was about
`dept_emp` and its 450,000 rows. Five repeats, five losses, the notice firing
twice and being ignored twice: we were asking for a table the model had no
interest in, and it declined.

The table now comes from the pending report's own claims, matched on word
boundaries so `dept_emp` is not found inside `current_dept_emp`. That is both
likelier to be acted on and more correct -- what this verdict scores is whether
the claims rest on counts, so the table to profile is the one being claimed
about. The fallbacks descend in how much they know: a table the snapshot
DESCRIBES before one it merely lists, since a zero-column entry is exactly the
view that caused this, and undefined leaves the notice its generic wording
rather than naming one wrongly.

The evals could not have caught this. Every test on this surface reports on
`engineering`, which is also the fixture's first table, so the wrong rule and
the right one produced the same name. The new test reports on a later table,
which is what makes the two candidates distinguishable.

* fix(agent): tell a wrongly-qualified profile call what to change

Read off the wire. `qwen3:8b` lost `database-assessment` five times out of five,
and the ledger said only that no table had been profiled. What it actually did
was call

  profile_table {schema: "ctx_3b6ba24865a80f993576ee1f566c5238",
                 table: "current_dept_emp"}

putting the snapshot fingerprint in the schema field -- a fair guess from a model
whose surrounding rules all speak of fingerprints and whose schema field was
described nowhere. The table was in the inventory. The qualifier was not.

It was answered "That table is not in the schema inventory this run captured.
Call inspect_schema for it first", which is untrue of the table and names a tool
a held run has just been narrowed out of holding. It repeated the identical call
three times, because nothing in the sentence identified anything to change, and
then spent the rest of its budget failing to report.

Three changes, none of them to what gets profiled:

- the refusal splits in two. A table that is absent keeps the old sentence; a
  table that is present under a name the caller qualified wrongly is told so and
  given the name to use, and is not sent to a tool it may not hold.
- the schema field describes itself, leading with "omit this unless the inventory
  qualifies the table", since omitting it is the right answer whenever the
  inventory names a table bare.
- resolution is untouched. #345 is why a named schema is never matched against a
  bare entry, and that protection still holds: nothing is profiled, the answer is
  still a refusal. Only the sentence changed.

Measured on the cell it was found in: qwen3:8b database-assessment, 0/5 before,
5/5 after.

* fix(agent): name the failing field, and ask a planless run for a plan

Two refusals that a model could not act on, both found by reading the wire
rather than the ledger.

The first is the largest measured shortfall on this project. `no-report` blocks
28 of the cells that are not locked, 18 of them losing to nothing else, and the
recording shows what it is from the model's side: `compose_report` refused with
"The arguments did not match the shape this tool declares", called again
unchanged, refused with the same sentence, for THIRTY-SEVEN turns until the run
ended having reported nothing. A `granite4.1:3b` data-analysis run spent 14 of
its 42 turns the same way. The sentence is true and unusable -- it names no
field, so there is nothing in it to correct. Zod knew the path and what was
expected there and this layer discarded it; it now crosses over, including
through `invalidEvidenceInput`, which is the one `compose_report` uses and which
took no detail at all. What is passed is server-authored, the path and the
expected type, never the value the model sent -- the rule `composedSqlOutcome`
states, for the reason it states it.

The second is the largest shortfall that had no sentence at all.
`no-plan-comparison` blocks 9 cells, 5 of them losing to nothing else, and
`shortfallNotice` returned null for it. The ledgers say what is missing is
knowledge, not diligence: `qwen3:4b` loses this cell 4 times in 5, and each
losing run holds four events -- started, context captured, report composed,
finished -- with no tool called at all. `compareBeforeReportNotice` cannot cover
that, because it names the two plan ids to compare and so can only speak once a
run HOLDS two. This one is gated on holding none and asks for the reading
instead.

An eval asserted the opposite of that gate -- that a planless run is not asked,
"because the nudge would be a lecture". That was judged without the measurement
and the measurement overturned it; the test now carries the numbers that did.

* fix(agent): accept a refusal whose reason begins on the next line

Plan mode's deliverable is one runnable statement or an explicit `NO STATEMENT:`
naming what the inventory lacks. Four losing runs across three models --
`qwen3:4b`, `qwen3.5:4b`, `nemotron3:33b` -- end like this:

    NO STATEMENT:
    The provided schema inventory includes the complete answer to the question,
    so a runnable SQL query is not needed. The question that would allow writing
    such a statement is: "..."

The marker line ends where it ends and the explanation begins on the next one.
Every one of those runs did exactly what the mode asks -- refused, said what was
missing, asked the one question -- and every one was scored `no-statement`,
because the reader only ever looked at the remainder of the marker's own line and
found it empty.

The #396 rule that empty check exists for is untouched: a marker with nothing
after it ANYWHERE is still `absent`, since the convention exists so the run says
what it lacks and a token that says nothing is not the ending the mode is for.
What moved is where "after it" is allowed to be. A fence does not answer for it
either -- prose is what is being waited for, and a run that followed its marker
with a code block still has not said what is missing.

`no-statement` blocks four cells, three of them at 4/5.

* test(ci): count the install script's backoffs instead of timing them

"does not sleep after the final attempt" checked its claim with a stopwatch:
two attempts with a 1s backoff had to finish under 1900ms, on the reasoning that
a second backoff would push the run past 2000ms. That measures the machine as
much as the script, and it failed at 1907.7ms on a laptop that was busy running
something else -- with nothing wrong in the script or the test's premise.

PATH is already replaced rather than prepended so the stub `bun` is the only one
in reach, which means a stub `sleep` beside it also wins. The test now reads the
waits the script actually asked for off that stub and asserts exactly one, of one
second. Same claim, counted rather than timed: exact, instant, and independent of
load.

* fix(agent): stop refusals from naming a tool the run cannot call

Two refusals told a run to do something it had no way of doing. Both are the
class of defect that has the best measured return on this project: three cells
went from 0/5 to 5/5 yesterday by making a refusal say what to change.

The deadline pair ended "Stop calling tools and finish with what has already been
established" and "Ask for something cheaper, or finish now." Finishing IS a tool
call. `compose_report` is ledger-only -- it reaches no database and is
short-circuited before the deadline gate -- so it still works for a run with
nothing left in its budget. A model that reads those sentences literally answers
in prose, and prose is scored `no-report`, which blocks 28 of the cells that do
not lock. Both now name that call and say why it works.

`TABLE_NOT_INVENTORIED` said "Call inspect_schema for it first". The comment one
line below it, written for the sibling refusal yesterday, already states why that
is wrong: the `no-table-profile` hold narrows a run to `profile_table` and
`compose_report`, so `inspect_schema` is gone. The measured sequence was two dead
refusals in a row -- held and narrowed, asked for a profile, names a table
slightly wrong, told to call a tool it no longer holds, calls it, told there is no
such tool. The inventory is in hand at the refusal site, so it now offers the
first few names from it instead of a tool. Six at most: a refusal that turns into
a catalog dump is a wall of its own.

Neither is gated on whether the run was narrowed. Naming real tables is the more
useful answer either way, and a run out of time should be told how to finish
whatever put it there.

* fix(agent): sample tool turns deterministically

This loop never set a temperature, so every run measured so far inherited
Ollama's defaults: temperature 0.8, top_p 0.9.

That is the wrong setting for what the harness measures. A cell counts as
working only at 5/5 consecutive passes, which makes the bar a variance test as
much as a capability one, and 26 of the cells that do not lock sit at 4/5 -- one
run away. A twelve-step tool chain sampled at 0.8 is a large and entirely
avoidable source of exactly that flapping. Choosing a tool and filling in its
arguments is a structural task; there is nothing a sample is meant to explore,
and a report wants the most likely sentence rather than a surprising one.

Found while checking a different hypothesis. Two independent research passes
recommended replacing the artifact UUIDs a model must copy between calls with
short handles, citing Anthropic's tool guidance and RULER's finding that UUID
needles degrade worst of any needle type. Probed directly before building it:
`qwen3:4b` returned both ids exactly, 10 times out of 10, with UUIDs and with
short handles alike. The published degradation is a long-context, many-id
effect; `compare_plans` passes two. So the large structural change was not
warranted and this one-line default -- named in the same research and easy to
miss -- was.

Asserted on the request body rather than on an outcome, because a default this
loop never states is a default nobody notices.

* feat(agent): judge a rewrite in one call, because the chain is what fails

`query-optimization` locks on 5 of 25 measured models. Every other surface is at
10-13. It is also the longest chain -- `inspect_plan`, `inspect_plan` on a
rewrite, `compare_plans` naming BOTH artifact ids, then a report citing them --
and the only call in this layer that needs two server-minted ids at once.

The lock rate tracks chain length rather than subject matter, and the numbers say
so twice. Across the ten weakest models the only surface any of them locks is
`planning`, which needs no tool call at all: 5 of 10 there, 0 or 1 everywhere
else. Across all 25, `operations` leads -- and it leads because the server does
the reading and the model authors no SQL.

So `propose_rewrite` reaches the same bar by a shorter road: two statements in,
and the server inspects both plans and records the comparison itself.

It is implemented as two recursive `handleCall`s, which is the whole design. Every
guarantee a manual `inspect_plan` carries applies unchanged -- drafted statement,
derived step id, audited operation, deadline admission, duplicate protection,
repair budget -- because it IS a manual `inspect_plan`, twice, with the server
supplying the sequencing. It declares no operationId of its own for the same
reason, and the test that counts operation ids is what would catch it growing one.
The comparison is the existing `comparePlans`, so the `plan-comparison` event the
verifier reads has one definition, not two.

Both plan readings' own words are handed back with the comparison. A first
version swallowed them -- the server had the ids, so why show them? -- and that
trades one shortfall for another: the report still has to cite, so a run whose
plans exist but whose ids it never saw can compare and then not report. What this
removes is the PAIRING, not the citing.

`inspect_plan` and `compare_plans` stay offered, so a run that wants to walk the
steps still may. The workflow rules name the one-call route first, following the
`operations` precedent of leading with the instrument. Narrowing keeps it, and
there it matters most: a narrowed run has lost `inspect_plan`, so the other two
tools both need a plan artifact it can no longer obtain.

* fix(agent): declare the smaller tool set to a prompted run too

A prompted run cannot emit `tool_calls`, so its tools are declared as prose: one
contract message, built once from the full selection and pushed before the loop.
When narrowing fires, the native path re-declares for free -- `takeTurn` is handed
`narrowedTools(record)` and the SDK simply stops offering what the run may no
longer call. The prompted path passed `undefined` and left the original contract
standing, while `handleCall` enforced the narrowed set regardless of protocol.

So a narrowed prompted run read a contract listing `run_read_query`, called it,
and was answered "There is no tool called run_read_query in this run." Every
`deepseek-r1` distill takes this path; between the four of them they lock 2 cells
out of 24.

The contract is now re-declared once, at the turn narrowing first applies, saying
plainly that the set has been reduced. `promptedToolContract` answers null for an
empty set, so a run with nothing left to declare says nothing.

The test is the other half of this, and it is the first test in the repository to
drive the prompted path at all -- which is why no test ever caught this. It needed
two things the harness did not have: `startRun` now takes `toolProtocol`, and the
scripted turns speak the protocol under test (a JSON object in ordinary text; a
native `tool_calls` reply leaves the SDK waiting for a tool result this path never
writes). Narrowing is tripped by the report reminder rather than the twelve-call
ceiling -- the same `narrowed = true`, two turns sooner.

* fix(agent): read the calls these models actually write

Two recoveries, both taken from real losing ledgers, both on turns a working run
never produces.

A `deepseek-r1:8b` assessment closed with 498 characters of prose holding six `{`
and five `}`: two well-formed claims, each citing an artifact id from one of its
own `profile_table` calls, and the outermost brace missing because the reply was
cut off. `objectsIn` only emitted a candidate when depth returned to zero, so both
readers saw nothing and the run scored `no-report`. The work was done and one byte
was missing. A truncated trailing object is now offered as a last candidate, after
every well-formed one, so a complete reply is read exactly as before. Closing what
the model left open changes no value: the result still has to parse and still has
to satisfy a tool's schema, so a truncation that lost a required field is refused
as it was. It cannot invent a field.

`deepseek-r1:7b` -- the only model in the fleet with no agent cell at any score --
writes its calls inside an envelope of its own invention:

    {"actions": {"inspect_schema": {"arguments": {"kind": "columns", …}, "type": "object"}}}

The intended call is legible twice over: the KEY names a tool the run holds, and
the nested `arguments` fits that tool's schema. The outermost object fits nothing,
so nothing was recovered and the run ended having called no tool at all. Envelopes
are now read by the tool NAME inside them rather than by a list of blessed shapes,
because the shapes are whatever a model improvises while the name has to be right
either way. The reader's existing rule holds: exactly one held tool named, or
nothing -- an envelope is a weaker signal than a schema match, so it may not be
where ambiguity gets resolved by guessing.

`readPromptedAction` also stops being gated on the prompted protocol. A native
model that narrates `{"action": …}` instead of calling was never read, while
`readPromptedPayload` on the very next line has always served both. There was no
reason for the two readers to disagree about which runs they serve.

Recovery reads intent and grants nothing: every recovered call goes through
`AGENT_TOOL_DEFINITIONS` and the audited pipeline, so a report citing an artifact
this run never produced is refused by the citation contract like any other.

* fix(agent): tell a run that read nothing what this workflow is scored on

`citeWhatYouReadNotice` fires only when the run HOLDS a usable artifact, because a
run with nothing to cite would be asked for the impossible. That guard is right,
and it left a hole: a run that took NO reading and reported off the captured
inventory holds nothing, hears nothing, and dies of `no-reading` never having been
told the bar exists.

It is worth an entire cell. `lfm2:24b` loses `operations` five times out of five,
and all five ledgers are four events long -- started, context captured, report
composed, finished -- in five to six seconds. It answers "eight tables with no
associated indexes" from the inventory it was handed and never calls
`inspect_operations`. It reads competently on other surfaces at 24B, so this is
not capacity: it is a run answering on turn one because nothing said the answer
had to rest on a reading.

The two sentences now divide by what the run holds. Artifacts plus the wrong
citation keeps the existing notice, which can name the ids it actually has. No
artifacts at all is sent to the reading tool instead. Saying "this run has taken
no reading" to a run that took six would be the false self-description this file
keeps catching, and three evals caught exactly that within a minute of the first
version.

NOT narrowed, and that is load-bearing: `AGENT_NARROWED_EXTRA_TOOLS.operations` is
empty, so narrowing here would remove `inspect_operations` -- the one tool the
sentence asks for. The same trap `no-plan-evidence` documents one branch below.

An eval asserted the old behaviour -- that such a run is told nothing at all. Half
of that reasoning survives and is why this notice still stays silent for a run
that has ids: it cannot be asked to CITE. It can be asked to READ.

* Revert "feat(agent): judge a rewrite in one call, because the chain is what fails"

This reverts commit c2ad675. It won nothing and it cost a locked cell.

Measured both ways. On the cell it was written for -- `qwen3:8b`
query-optimization, the surface that locks on 5 of 25 models -- five runs after it
landed produced 1/5, and the ledgers show the model never called `propose_rewrite`
once: every run walked the old `inspect_schema`/`inspect_plan` road anyway. And the
watch set over the four models that lock all six surfaces found `gemma4:26b`
query-optimization at 3/5, down from 5/5, failing `no-plan-comparison` and
`no-report`.

The likeliest cause is the part that touches a passing run: this workflow's rules
gained a sentence and were reordered to name the one-call route first, so every
optimization run's prompt changed. The tool itself only fires when called, and it
never was.

The reasoning behind it still looks right -- chain length tracks the lock rate,
and `operations` leads because the server does the reading. What is now measured
is that offering a shorter road does not make a model take it, and that paying for
the offer in the rules of a surface where four models already lock is the wrong
trade. If this returns it should arrive without touching the rules of a working
surface, and be measured on a cell that is already lost.

The prompted-protocol test from a later commit is kept: it lands in the same
describe block and has nothing to do with this.

* fix(agent): stop double-prefixing a statement the model already EXPLAINed

Measured across 133 optimization runs: 21 `inspect_plan` calls arrived with the
statement already prefixed, 12 were refused, and at least three runs lost
outright to it. `qwen3:8b` does it in 9 of its 15 runs on that surface.

`composeEstimatingExplain` prepends the prefix itself, so
`inspect_plan({sql: "EXPLAIN SELECT …"})` sent
`EXPLAIN QUERY PLAN EXPLAIN SELECT …` and the model was handed one line of
SQLite: `near "EXPLAIN": syntax error`. That is unactionable when the model did
not write the outer EXPLAIN, and the shared rules then tell it not to send the
same statement again. One run tried `EXPLAIN QUERY PLAN SELECT …` -- the only
repair reachable from that message -- and got the same error. Three abandoned the
tool and reported nothing.

Stripped rather than refused: the intent is unambiguous, the model asked for the
plan of a statement and named the operation twice, and this function already
trims whitespace without asking. A refusal would teach and still cost the run the
turn it has already spent.

`EXPLAIN ANALYZE` is deliberately not matched. It asks to EXECUTE, which is a
different request and one this run may not make -- removing it would quietly turn
a refused execution into an accepted estimate. Composing it through is what puts
the word in front of the guard that refuses it.

Found by an audit of every optimization ledger, which also found the larger thing
this does not fix: the workflow's first rule orders `inspect_schema kind="indexes"`,
the sample database has no secondary indexes, and the zero-row read is answered
"There is no object of that name in this database" -- false, and the opening turn
of 79 of 133 runs. That is next, and it needs its own measurement because it is a
refusal text a locked cell can see.

* feat(agent): decide sampling per model, not once for all twenty-five

For days this repository looked for one setting that suits 25 local models on six
surfaces, and every landing won some cells and cost others. Sampling is the
clearest case and the reason this module exists.

The loop set no temperature, so every run inherited Ollama's 0.8. Pinning it to 0
won five cells outright -- a cell locks only at 5/5 consecutive passes, so the bar
is a variance test as much as a capability one. It also cost `qwen3:8b` its
`query-optimization` cell, 3/5 down to 0/5. Reading all fifteen of its ledgers on
that surface: at 0.8 it opened with `inspect_plan` on 3 of 5 runs and answered all
three; at 0 it opened with `inspect_schema` on 10 of 10 and lost every one. The
opening move decides the run -- `inspect_plan` first is 3/3 answered,
`inspect_schema` first is 1/12 -- and determinism pinned it to the losing branch
instead of letting it wander into the winning one.

Both measurements are true. No global number holds both.

`src/lib/agent/models/` is where a model's own settings live, and the rule that
keeps it from becoming a pile of invented constants is in the module header: the
default is the whole policy, a profile may only contradict it where a measurement
required it, and every override carries the numbers that bought it. `measured` is
not documentation but the admission ticket -- a test refuses a profile that
arrives without one. A model with no profile is not unsupported; it gets the
default, which is what every locked cell was measured on.

An override's scope is as narrow as its evidence. `qwen3:8b` locks its other five
surfaces deterministically, so its exception is keyed to the one cell that needs
it rather than to the model. Keys are lower-cased because the same weights answer
to more than one spelling and an exactly-matched key would stop applying the day a
tag changed.

* refactor(agent): one file per model, created when a model earns one

The register moves to `models/index.ts`, the contract to `models/profile.ts`, and
`qwen3:8b`'s settings to `models/qwen3-8b.ts` beside them. A model's own file is
where its own measurements and its own overrides live.

The contract is its own module rather than part of the barrel so a profile can
import the shape without importing the register that lists it -- otherwise every
profile would depend on every other one through the barrel, and adding a model
would be a change to all of them.

There are deliberately not 25 files. A file is created when a measurement shows
the default is wrong for that model. Twenty-four files holding nothing measured
would not be tidiness: they would be an invitation to fill them with plausible
constants nobody can justify later, and the whole value of the directory is that
every line in it answers to a number. A model with no file is not unsupported --
it gets the default, which is what every locked cell was measured on.

`qwen3-8b.ts` also records what its own override is working around rather than
fixing: the workflow's first rule orders an `inspect_schema kind="indexes"` read,
the sample database declares no secondary index, and the zero-row answer is
"There is no object of that name in this database" -- false, unlogged, and the
opening turn of 79 of 133 optimization runs. When that refusal tells the truth the
losing branch stops being a trap, and this profile should be re-measured at the
default before it is kept. That note belongs in the file the next reader will open,
not in a backlog.

* fix(agent): an empty index inventory is an answer, not a missing object

`CATALOG_MATCHED_NOTHING` told every empty catalog read the same thing: "There is
no object of that name in this database." For a column read that is right and the
eval above it says why. For an index or relation read it is false -- the objects
were found and declare none, which is an ANSWER to an optimization question rather
than a failed lookup.

It is the opening turn of most optimization runs. That workflow's first rule
ORDERS an `inspect_schema kind="indexes"` read; the sample database declares no
`CREATE INDEX` anywhere, so the call returns zero row…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants