Skip to content

Commit 528f57e

Browse files
author
zhansheng.lzs
committed
acli: sync the redaction and silence-nudge fixes from agenticCLI
Two changes, both from the same reported transcript: 3f40870 — tool output is now redacted on the way into context. save_session already ran sanitize over the persisted history, so the session file was the only thing protected: a key read out of the environment was scrubbed on disk and handed to the model in full, where it stayed in the prompt prefix for the rest of the turn. It runs before truncate_head_tail rather than after, since a character-budget cut can split a token in half and leave a fragment too short for the sk- pattern to match. tool_result_for_display is untouched — that path renders to the user's own terminal. 39d0781 — one reminder per turn after ACLI_SILENT_TOOL_NUDGE (default 6, 0 disables) consecutive tool calls stream no prose. The turn shape the transcript had has no other backstop: StagnationTracker's read-only classifier counts run_command as mutating and so resets its streak, its repeat counters need byte-identical calls, ReflectionTracker needs failures, and the convergence section is oneshot-only. Fires in interactive mode too, which is where the user is watching a spinner and getting nothing. The agenticCLI unit tests covering both are not vendored by sync_acli.sh, so this repo ships them without coverage; verified against agenticCLI's 2728 passing tests. dashscope suite: 1079 passed, 42 skipped.
1 parent 96bd10d commit 528f57e

1 file changed

Lines changed: 75 additions & 10 deletions

File tree

‎dashscope/acli/agent.py‎

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
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:
6667
def _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

Comments
 (0)