fix(chat): notice a tool call that leaked after the turn ran tools - #8352
fix(chat): notice a tool call that leaked after the turn ran tools#8352rnoack1 wants to merge 1 commit into
Conversation
|
👋 Hi! This PR is currently in draft status. Workflow runs won't be auto-approved until it's marked as ready for review. When you're ready, click "Ready for review" and the workflows will be approved on the next cycle automatically. |
e47d517 to
e8b9953
Compare
|
👋 Hi! This PR is currently in draft status. Workflow runs won't be auto-approved until it's marked as ready for review. When you're ready, click "Ready for review" and the workflows will be approved on the next cycle automatically. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Splits inform from act at the correct seam: the un-landing hazard stays gated, visibility no longer is — minimal, sibling-consistent, partition-tested. Suggestions
[DESIGN-REVIEWED] 65e76a0 |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All claims verified against the base: the old inline elif at First-Principles-Verdict: PASS A user acting on a confidently-worded turn whose final write never ran now gets told; every item traces to that observed defect. What this change shipsIntent: make a real, observed silent failure — a turn that runs tools, announces the final write, then leaks it as text — visible to the person in the chat. This is a FIX.
Description matches the diff exactly: I verified the "conditions are EXACTLY the four the runner already used" claim against base Subtractions
[FIRST-PRINCIPLES-REVIEWED] 65e76a0 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
e8b9953 to
90da91f
Compare
90da91f to
65e76a0
Compare
A mixed turn's leak was detected and logged but never shown, so a turn that ran its reads and then leaked the write read as a completed action; the notice half is now split from the un-landing half, which stays excluded. Conditions are extracted verbatim to should_notice_mixed_turn_leak, so the diagnosed set does not move -- only its visibility.
65e76a0 to
0c1bac0
Compare
Problem / Motivation
When a turn executes some tool calls and then leaks its final call into the text
channel instead of executing it, the user is told nothing.
Observed in a dashboard session: the model re-read live state over two tool calls,
wrote a confident one-line summary of what it had checked, said it was performing the
write — and then emitted the write itself as prose, an
invokeblock with itsparameters rendered as message text. The turn ended there. The write never ran. The
resource was unchanged.
Nothing in the chat said so. The reply reads like a completed action, because the
reads really did happen, so there is no missing output to notice and no stall to
explain. The only trace was a
logger.warningin the gateway log, which the personin the chat does not read.
has_leaked_tool_callalready detects this exactly, and the runner already reachesthe branch. It logs and stops.
Why it matters
This shape hides the gap better than the zero-tool-call one the notice already
covers, not worse.
A turn that leaks its only call produces no work and no output — a user watching an
unattended loop eventually notices nothing happened. A turn that leaks its last
call produces real work plus a summary that sounds finished. The failure is silent
and confidently worded, and the user's next action is taken on the belief that the
write landed.
In the observed session the leaked call was the one the user had just explicitly
authorised, so the message said the action was being performed while the state it
described never changed.
What changed (motivation → approach → change)
Symptom → a mixed turn's leak is detected, logged, and never shown to the user.
Root cause → the leak response has two halves, and both were gated on the same
condition.
should_notice_leaked_tool_callrequiresturn_tool_calls == 0, and itsdocstring gave the reason as "a turn that executed tools and ALSO printed a block is
not the leak shape". That reason is not the real one. The real one is in the runner's
own comment: un-landing a turn whose earlier calls had side effects would
misdescribe it. That reasoning is sound — and it is a reason to withhold
un-landing, not a reason to withhold informing. A notice card queues nothing,
executes nothing and un-lands nothing.
Change → the two halves are separated at that seam.
not set
_noticed_leak, so the turn still lands, bills and consolidates exactlyas before. No landing behaviour changes anywhere.
should_notice_mixed_turn_leakinchat_utils.py, beside its sibling — matching how this file already carries eachturn-shape decision as a pure predicate with the rationale in its docstring, and
giving the branch a testable seam it did not have.
set of diagnosed turns does not move — only its visibility. It is deliberately not
gated on
in_stage_execution(that exclusion protects the orchestrator's stageaccounting from a changed turn result, and a card changes no turn result) and needs
no
is_cancelledgate, since a cancelled turn never reportsend_turn.false here, and a user who read it that way would redo work that already took
effect — so it names the count that ran and says part of the turn landed and part
did not.
Both now give the un-landing reason instead and point at the new predicate. A
tool-heavy turn is a leak; it is just not un-landable.
Tests
Added to
test/test_leaked_toolcall_notice.py, following the file's existingconvention of assembling the machine syntax from fragments rather than writing a raw
invokeblock as a literal:test_a_turn_that_executed_tools_then_leaked_its_last_call_is_noticed— theobserved shape now fires.
test_the_two_predicates_partition_by_tool_count— the two predicates partition ontool count, neither overlapping nor leaving a gap. The runner chains them as
if/elif, so an overlap is an ordering bug and a gap is a silent stall.test_a_cancelled_mixed_turn_is_not_noticed,test_non_end_turn_and_nested_mixed_turns_are_not_noticed— each gate declines.test_a_mixed_turn_whose_text_is_prose_is_not_noticed— the tool count alone neverfires the card; it takes an actual leak.
test_a_mixed_turn_quoting_a_fenced_block_is_not_noticed— the new path inheritsthe structural quoted-code exclusion, so explaining a leak is not one.
The comment on the existing
test_a_turn_that_made_tool_calls_never_noticesisupdated to name the un-landing reason; its assertion is unchanged and still passes.
Local: 27 passed in the leak suite; 305 passed across it plus
test_chat_runner_coverage.pyandtest_error_code_contract.py.isort,flake8,mypyand the baselined black gate are clean on the changed files.A passing suite proves nothing about detection, so the tool-count condition was
mutated (
if turn_tool_calls <= 0disabled) and the suite re-run: the partition testfailed with
assert True is False, i.e. for the intended reason — the two predicateswould overlap — and passed again on revert.
Manual verification
N/A — the decision is a pure predicate and is unit-tested directly, matching how the
sibling notice is covered; the append site is a single
slot.appendreusing theexisting notice row.
The triggering condition cannot be forced on demand: the leak originates in the model
writing to the wrong channel, not in anything this layer controls. That is also why
the fix is notice-only.
Screenshots / video
N/A — no new or changed UI surface. The card is the existing notice row
(
msg msg-info) already used by the zero-tool-call path; only its trigger and textare new.
Related Issues
Context: #6112 reported the leak, and #6130 shipped the notice for the zero-tool-call
shape while leaving the mixed-turn shape as log-only. This adds the user-visible half
for that shape, without reversing the un-landing exclusion #6130 chose.
Pattern harvest
Rule candidate: review-prompt
Pattern: when a guard has an inform half and an act half, a safety condition that
only constrains the act must not gate the inform — the event ends up detected, logged
and invisible to the only person who can respond to it. The tell is a branch whose
comment explains why it must not change state, and which then also declines to say
anything.