Repository navigation
feat(agent): let plan mode read the database it plans against, and draft the SQL - #396
Conversation
…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.
There was a problem hiding this comment.
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
draftedis 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 theNO 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.
| } | ||
| if (open !== null) block = block ?? fencedBlock(open.tag, open.lines); | ||
|
|
||
| if (refusal !== null) return { kind: "refusal", detail: refusal }; |
| */ | ||
| 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 }; |
| // 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 }; |
| 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; | ||
| } |
| if (prose === undefined || !PLAN_REFUSAL_MARKER.test(prose)) return null; | ||
| return prose.replace(PLAN_REFUSAL_MARKER, ""); |
| (`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.
|
All six verified against the code, all six real, all six fixed in 1. A bare 2. A conflicting engine tag was relabelled — the sharpest of the six. 3. Only the first doubled delimiter unescaped — 4. Quoted aliases ended the comma list — the alias step now reads with Findings 3 and 4 belong together: each made the validation quietly weaker than the sentence 5. The marker inside a non-query fence — fixed by removing the second reader rather than by Worth recording why the old code's defence failed, because the reasoning was sound and the covered 6. Three surfaces still publishing the retired contract — 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: And one fixture was self-contradictory — a Gates green after the fixes: |
There was a problem hiding this comment.
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
replacealways 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
draftedis run-global, so oneplan-statement-draftedevent 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
ONLYis a PostgreSQL table modifier, not the end of the table position. With this rule, valid SQL such asSELECT * FROM ONLY paymentsreports no referenced tables, so a missing table escapesunknownTablesand 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.
|
Correction to my reply above, on finding 5. I wrote: " zod v4 probes The mistake is worth naming precisely, because it is the same class this PR was congratulating itself Fixed in Measured rather than argued this time: built both ways and grepped the client chunks for zod's own
|
…, 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.
|
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.
…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.
…#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 …
…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…



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
dvdrentalsample 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.tsskipped the whole context-establishing path for a planning run and consulted onein-process map instead:
heldSnapshotForConnectionis an LRU held in the serving process, and the only thing that fills it isa 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_RULESfor the grounded case. It fixed the wording of a path that israrely 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:
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
verifyPlanningGoalpassed a planning run that left any non-empty prose. The lecture above scoredas 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_statsandsqlite_stat1— catalog reads, zerotable scans. A
COUNT(DISTINCT col)per column would have been a full scan, which is unacceptable in amode whose selling point is "open it against production without worrying".
Three honesty requirements follow, because these numbers are estimates:
pg_stats.n_distinctis negative when it expresses a ratio, converted only against a row estimateand marked derived;
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_statscarries sample values inmost_common_vals; theyare 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.tsxalready reads, so the editor hand-off is never lost to atag 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 untilsomeone decides what a plan of it hands back.
operationsis the exception by decision: it reads noschema 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 onequestion 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-draftedevent 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-statementshortfall: a planning run passes whenit 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.
renderProsewithonApplySql, so the sameDELETEalso got an unmarked "Apply to editor" buttonfrom
rich-text.tsxdirectly above the marked card. The rail now withholds the prose hand-off for aclosing 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.
readOnly === falseis the statement guard'sobjection, 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, andto a screen reader, as SQL that can delete their data. It now reports what the guard actually said,
and shows the reason.
design record asserted the opposite decision as settled.
plan-statement.tsclaimed an agreement withrich-text.tsxthat did not hold on the fence infostring. Fixed by extracting the shared predicate to
src/lib/sql/fence-tags.tsrather than byediting the docblock, so the two readers cannot drift again.
Honest limits, stated in the docs
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.
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 countsonly); 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.