Skip to content

fix(agent): remind a run that read the database and then narrated - #416

Merged
cevheri merged 2 commits into
mainfrom
fix/remind-a-run-that-read-and-narrated
Aug 18, 2026
Merged

cevheri merged 2 commits into
mainfrom
fix/remind-a-run-that-read-and-narrated

Conversation

@yusuf-gundogdu

Copy link
Copy Markdown
Member

A turn with no tool calls ends the run. For a model that established nothing that is the right ending: there is no report to ask for.

But a run that HAS read something and then writes its findings as prose has done the whole job except the one call that records it. Ending there throws the readings away: the ledger keeps the artifacts, compose_report was never called, and the goal verifier scores the run no-report.

Measured

Three models did exactly that against a local Ollama endpoint, each after reading the database:

Model Readings taken Then
mistral-small3.2:24b 4 prose
lfm2:24b 11 on a data-analysis run prose
qwen3.5:2b 42 prose

None was refusing to report. Each had answered in the register a chat model answers in.

It is not a fixed property of the model either

mistral-small3.2 failed Operate with zero tool calls, which reads as a model that cannot use tools. It can — given the same tools and a simple prompt it called inspect_operations immediately.

So the exact request the agent had sent was captured and replayed five times:

Attempt Result
1 6 tool calls
2 "I'm sorry, but I don't have the necessary tools"
3 "I don't have access to the necessary tools"
4 4 tool calls
5 6 tool calls

Same request, same model, same server: 60%. Because the run ended at the first prose turn, that 40% was being recorded as a model that could not drive an agent run at all.

What this changes

The loop sends one message when a prose turn arrives AFTER at least one tool call, and takes the turn again. Deliberately narrow:

  • never when the run has taken no readings, which is a model that is stopping rather than hesitating
  • once, because a model that will not call the tool the first time it is asked is stopping too, and a second telling spends a turn to learn that
  • a message, not a rule change: it spends no statement, consults no deadline admission and reaches no database

Verification

Measured after the change, confirmed on the ledger in .workflow-data:

Model Workflow Before After
mistral-small3.2:24b Operate no-report answered — four readings, then report-composed
lfm2:24b Investigate no-report answered
lfm2:24b Analyze no-report still no-report

The last row is the honest bound: the reminder was delivered after nine readings and the model narrated again. This rescues runs that were one call short, not every run that ends without a report.

Eval scripts

Ten scripts gain one prose turn. scriptedModel treats a script running out as simulated process death (agent-scripted-model.ts:73), so a script that ended exactly on its prose turn now needs the turn the reminder asks for.

No assertion changes, including #341 F2's: a competent investigation that never reports is still succeeded and unanswered, and now says so about a run that was reminded and narrated anyway.

Scope

No provider is touched: every file under src/lib/llm, plus provider-registry.ts and model-adapter.ts, is unchanged, so Gemini and OpenAI deployments behave exactly as before.

format, lint, typecheck, knip, test (all 30 groups) and build pass locally.

yusuf-gundogdu and others added 2 commits August 18, 2026 02:50
A turn with no tool calls ends the run. For a model that established nothing that
is the right ending: there is no report to ask for. But a run that HAS read
something and then writes its findings as prose has done the whole job except the
one call that records it, and ending there throws the readings away: the ledger
keeps the artifacts, `compose_report` was never called, and the goal verifier
scores the run `no-report`.

Measured against a local Ollama endpoint, three models did exactly that, each
after reading the database:

  mistral-small3.2:24b   4 readings, then prose
  lfm2:24b              11 readings on a data-analysis run, then prose
  qwen3.5:2b            42 readings, then prose

None was refusing to report. Each had answered in the register a chat model
answers in. Replaying one captured request five times showed the same model
calling tools three times and saying it had none twice, so the ending was not a
property of the model either: it was whichever way that turn happened to land.

The loop now sends one message when a prose turn arrives AFTER at least one tool
call, and takes the turn again. Deliberately narrow:

  - never when the run has taken no readings, which is a model that is stopping
  - once, because a model that will not call the tool the first time it is asked
    is stopping rather than hesitating, and a second telling spends a turn to
    learn that
  - a message, not a rule change: it spends no statement, consults no deadline
    admission and reaches no database

Measured after the change: `mistral-small3.2` operations went from `no-report` to
`answered`, confirmed in `.workflow-data` (four readings, then `report-composed`).
`lfm2:24b` investigation likewise. `lfm2:24b` data-analysis still ends
`no-report`: the reminder was sent after nine readings and the model narrated
again. So this rescues runs that were one call short, not every run that ends
without a report.

Ten eval scripts gain one prose turn. `scriptedModel` treats a script running out
as simulated process death (agent-scripted-model.ts:73), so a script that ended
exactly on its prose turn now needs the turn the reminder asks for. No assertion
changes, including #341 F2's: a competent investigation that never reports is
still `succeeded` and `unanswered`, and now says so about a run that was reminded
and narrated anyway.
…d turns

The reminder names `compose_report`, so it may only reach a run that holds
that tool, and it costs a turn, so it may only be sent where a turn remains.
Neither bound was applied.

A PLANNING run holds no tools at all. `anyToolCalled` was set the moment any
tool-call part arrived, including one the offered-set check then refused, so a
plan run that reached for a tool was told to call `compose_report` — a rule
stated to a model whose tool set cannot satisfy it, which is the #350/#356
failure. The same looseness made the sentence untrue in agent mode: a name the
model invented reached nothing, and the run was still told it had taken
readings and then asked to cite artifacts it has not got.

The flag is now set only for a tool this run was actually given, read from the
set handed to the SDK. Planning is covered by the same line rather than by a
second check: `declaredTools` returns nothing outside agent mode.

The turn ceiling was the other gap, and it sits exactly where this fix is
aimed. `AGENT_REPORT_RESERVE_NOTICE` already tells a model to wrap up two
turns from the end, and a small model wraps up in prose — so the reminder was
most likely to be spent on a turn the loop would not grant. Measured with
`maxTurns: 2`: a run that read and then narrated ended `succeeded` /
`model-stopped` before this change and `failed` / `turn-limit` after it, which
is a false account of why the run ended. The reminder now reads the loop's own
ceilings before spending one.

Five scripted prose turns come back out of the eval scenarios. Every one of
them was a run reaching for a tool it was never offered — `read_monitoring`,
`recommend_change`, `compare_plans`, a planning `run_read_query` — so they
were the defect's own footprint rather than scenarios that changed.

Tests: five for the reminder itself, which had none of its own.
@cevheri
cevheri force-pushed the fix/remind-a-run-that-read-and-narrated branch from 828c69a to 6d6e594 Compare August 18, 2026 00:51
@cevheri

cevheri commented Aug 18, 2026

Copy link
Copy Markdown
Member

Rebased onto main (the conflict with #418 was pure adjacency — both added a block after AGENT_REPORT_RESERVE_NOTICE, and both are kept) and pushed one maintainer commit on top. The measured behaviour this PR is built on stands; two bounds the reminder needs were missing.

A planning run was told to call compose_report. anyToolCalled was set the moment any tool-call part arrived, including one the offered-set check then refused. Planning mode holds no tools at all, so a plan run that reached for one got the reminder. Proven by adding a single assertion to this PR's own a tool the run was never offered is refused without reaching the database:

expect(sent.some((m) => m.includes("compose_report"))).toBe(false);
→ Expected: false   Received: true

That is the #350/#356 shape, and the same looseness made the sentence untrue in agent mode: a name the model invented reached nothing and the run was still told it had taken readings, then asked to cite artifacts it has not got. The flag now reads the set the SDK was actually handed, which covers planning by construction — declaredTools returns nothing outside agent mode.

The reminder could spend a turn the loop would not grant. Measured with maxTurns: 2, a run that read and then narrated:

status stopReason
before this PR succeeded model-stopped
with this PR failed turn-limit

A model that stopped on its own is not a run that ran out of turns, and the ceiling is exactly where this lands: AGENT_REPORT_RESERVE_NOTICE already tells the model to wrap up two turns from the end, and a small model wraps up in prose. remindToReport now reads the loop's own ceilings before spending one.

Five scripted prose turns came back out of the eval scenarios, and every one was a run reaching for a tool it was never offered — read_monitoring, recommend_change, compare_plans, a planning run_read_query. They were the defect's footprint rather than scenarios that changed.

Five tests added for the reminder, which had none of its own — the one-shot and the no-call guard were only pinned indirectly, by scripts running out.

format, lint, typecheck, knip, test:ci (297 core + 30 groups), build and coverage:check (100.00%, 36695/36695) pass locally.

@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit 71a07da into main Aug 18, 2026
23 checks passed
@cevheri
cevheri deleted the fix/remind-a-run-that-read-and-narrated branch August 18, 2026 01:14
cevheri added a commit that referenced this pull request Aug 18, 2026
…d what the nudges do not (#421)

* docs(agent): say which runtime the measured rows came from, and record what the nudges do not

`methodology.md` named one commit, `907e83c`, and the pages under it report figures that
commit cannot produce: the rows marked *(fixed)* and every "after" column were taken with
the report reminder and the present-before-report notice in place, and those landed as
#416 and #417. A reader following the method against `907e83c` alone would fail to
reproduce them and have no way to see why. Both commits are named now, along with the
review narrowing each notice took before it landed and why the recorded runs sit inside
the narrower triggers.

B51 records what those two notices do NOT do. `recordEvent` is called for what a run did
— the capture, the drafted statement, the closing prose, the answer, the report — and
never for what the server said to the model, so a rescued run and a run that needed no
rescue leave the same ledger, and a `compose_report` the loop withheld leaves no trace
that a call was made. That is a measurement problem before it is a design one, because
`docs/llms/` is built by reading those ledgers and its whole claim is that each figure
comes from an observed run.

The entry carries the second half too: three notices now, each with its own boolean, its
own guard set and its own delivery, and the guards are the part that keeps being got
wrong — both of the new ones shipped with a condition that named one thing and read
another, and both were caught in review rather than by a gate.

* docs(agent): name all three measured revisions, and scope "once" to a drive

Three findings from review, all three verified against the code and the history.

**The revision table was missing one.** #406 (`df15e56`) is an ANCESTOR of the
`907e83c` baseline, so the before-figures on `qwen3.8` (`no-answer` in 84.9s) and
`muse-glimmer` (`no-answer` after 234.4s) are reproducible from neither commit this
page named — they are from `7256f93`, before that fix. "Everything else is the
baseline" was therefore wrong for exactly the two rows a reader is most likely to try
to reproduce. All three revisions are named now, in a table that says which figures
belong to which.

**"One-shot" was true of a drive, not of a run.** The three booleans are `let`s inside
`runInvestigation`, and that function is also what resumes a run a dead process left
running — so a resumed drive starts with all three false and can repeat a notice. Two
of the three are partially covered by accident: the present-before-report notice reads
`answer-composed` off the ledger, but a REFUSED `present_answer` writes no event and so
is not covered; nothing bounds the report reminder or the reserve notice across a
resume at all.

That is the same defect B51 already describes rather than a second one, so it is folded
into that entry: recording delivery is what would make "once" mean once. The
declarations in `investigation.ts` say it too — a comment-only change, because a reader
reaching for `reserveAnnounced` should not have to derive the scope from the resume
path three hundred lines up.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants