4242 UserSupplement ,
4343 normalize_for_model ,
4444 sanitize ,
45+ sanitize_text ,
4546 text_of ,
4647 tool_result_for_display ,
4748 tool_result_for_history ,
@@ -66,14 +67,22 @@ def _classify_outcome(successes: int, failures: int) -> str:
6667def _tool_message (tool_call : ToolCall , result : str ) -> dict :
6768 """Build the history entry answering ``tool_call``.
6869
69- The restore note must be attached here, at truncation time: later
70+ Redaction happens here, ahead of truncation. ``save_session`` already ran
71+ ``sanitize`` over the on-disk copy, so before this a key read out of the
72+ environment was scrubbed from the session file and still handed to the
73+ model in full — the persistence path was protecting the wrong reader.
74+ Scrubbing after truncation would not work either: ``truncate_head_tail``
75+ cuts on a character budget and can split a token in half, leaving a
76+ fragment that matches no pattern.
77+
78+ The restore note must be attached here for the same reason: later
7079 shrinking skips a result that already carries an omission marker, so the
7180 largest results would end up with no way to be read back.
7281 """
7382 return {
7483 "role" : "tool" ,
7584 "content" : tool_result_for_history (
76- result ,
85+ sanitize_text ( result ) ,
7786 note = restore_note (tool_call .name , tool_call .arguments ),
7887 ),
7988 "name" : tool_call .name ,
@@ -248,6 +257,20 @@ def __init__(
248257 )
249258 except ValueError :
250259 self .converge_hard_ratio = 0.85
260+ # Silence nudge: one reminder per turn after N tool calls have gone by
261+ # without any prose reaching the user. Mode-independent on purpose —
262+ # the shape this exists for is an interactive turn of *distinct*
263+ # run_command calls, which every existing backstop misses:
264+ # StagnationTracker's read-only classifier counts run_command as
265+ # mutating and so resets its streak, its repeat counters need
266+ # byte-identical calls, and _convergence_section is oneshot-only.
267+ # Env: ACLI_SILENT_TOOL_NUDGE (0 disables).
268+ try :
269+ self .silent_tool_nudge = int (
270+ os .environ .get ("ACLI_SILENT_TOOL_NUDGE" , "6" ),
271+ )
272+ except ValueError :
273+ self .silent_tool_nudge = 6
251274 # Hard acceptance gate (oneshot only): run the project's test command
252275 # before accepting a final answer, and send the raw output back when
253276 # it fails. Built here, ahead of the policy text below, so the prompt
@@ -357,6 +380,10 @@ def __init__(
357380 self .turn_subagents = 0
358381 self .turn_mcp_calls = 0
359382 self .turn_skills = 0
383+ # Consecutive tool calls that streamed no prose, and whether the
384+ # silence nudge has already been spent this turn.
385+ self ._silent_tool_calls = 0
386+ self ._silent_nudge_injected = False
360387 # Explicit /skill invocations to count on the next turn
361388 self ._pending_skill_names : list [str ] = []
362389
@@ -570,6 +597,33 @@ def _convergence_section(self, loop_index: int) -> str:
570597 self .converge_hard_ratio ,
571598 )
572599
600+ def _silence_section (self ) -> str :
601+ """Inject one reminder when tool calls stop producing prose.
602+
603+ Spent at most once per turn. Repeating it every iteration teaches the
604+ model to skim past it, which is the failure mode prose instructions
605+ already have — the point of a structural backstop is that it fires on
606+ a count rather than on the model's willingness to re-read advice.
607+
608+ Deliberately not gated on ``oneshot``: the user watching a spinner for
609+ five minutes with no text is the interactive case, and the oneshot
610+ runs already have the convergence and read-only-cap sections.
611+ """
612+ if self ._silent_nudge_injected or not self .silent_tool_nudge :
613+ return ""
614+ if self ._silent_tool_calls < self .silent_tool_nudge :
615+ return ""
616+ self ._silent_nudge_injected = True
617+ return (
618+ "\n ## ⏸ You have gone quiet\n "
619+ f"{ self ._silent_tool_calls } tool calls in a row and not one word "
620+ "of it reached the user.\n "
621+ "Before the next call, say in one or two sentences what you are "
622+ "doing and what you have found so far. If the calls were "
623+ "collecting information to answer a question you can already "
624+ "answer, answer it instead of calling again.\n "
625+ )
626+
573627 async def _recall_memory (self , user_input ) -> str :
574628 """Search for relevant profile info and format as context.
575629
@@ -652,6 +706,8 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]:
652706 self .turn_tool_calls = 0
653707 self .turn_subagents = 0
654708 self .turn_mcp_calls = 0
709+ self ._silent_tool_calls = 0
710+ self ._silent_nudge_injected = False
655711 try :
656712 active = get_skill_manager ().active_packages (user_input_text )
657713 names = [p .name for p in active ] + self ._pending_skill_names
@@ -728,26 +784,28 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]:
728784 f"messages → { len (self .messages )} [/dim]" ,
729785 )
730786
731- # Reflection / stagnation / convergence hints are volatile, so
732- # they ride at the tail of the request instead of being folded
733- # into the system message. Rewriting messages_with_system[0] on
734- # every iteration changed the whole prompt prefix byte-for-byte and
735- # defeated provider-side prompt caching, where cached input costs
736- # roughly a tenth of uncached. The system message and the
737- # accumulated history below it stay append-only and byte-stable.
787+ # Reflection / stagnation / convergence / silence hints are
788+ # volatile, so they ride at the tail of the request instead of
789+ # being folded into the system message. Rewriting
790+ # messages_with_system[0] on every iteration changed the whole
791+ # prompt prefix byte-for-byte and defeated provider-side prompt
792+ # caching, where cached input costs roughly a tenth of uncached.
793+ # The system message and the accumulated history below it stay
794+ # append-only and byte-stable.
738795 #
739796 # The hint has to be a user message, not a second system message:
740797 # the Anthropic converter keeps only the last system message, so a
741798 # second one would replace the real prompt outright, and the Tongyi
742799 # converter only reads messages[0] as system.
743800 #
744- # All three sections are rebuilt every iteration because tracker
801+ # All four sections are rebuilt every iteration because tracker
745802 # state changes as tools run; each returns "" when its condition
746803 # is not met, so an empty hint costs no message at all.
747804 hint = (
748805 self ._reflection_section ()
749806 + self ._stagnation_section ()
750807 + self ._convergence_section (_loop_i )
808+ + self ._silence_section ()
751809 ).strip ()
752810 request_messages = (
753811 [* messages_with_system , {"role" : "user" , "content" : hint }]
@@ -904,6 +962,13 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]:
904962 tool_calls = [tc for tc in tool_calls if tc .name ]
905963
906964 self .turn_tool_calls += len (tool_calls )
965+ # Silence is per-iteration, not cumulative over the turn: an
966+ # iteration that streamed prose did talk to the user, so the run
967+ # of unheard calls starts over from the next one.
968+ if full_content .strip ():
969+ self ._silent_tool_calls = 0
970+ else :
971+ self ._silent_tool_calls += len (tool_calls )
907972 for tc in tool_calls :
908973 if tc .name in ("delegate" , "subagent_invoke" ):
909974 self .turn_subagents += 1
0 commit comments