Summary
channels.sendProgress defaults to true (config/schema.py:33, channels/base.py:31),
yet on a default install it has nothing to deliver and behaves exactly like false.
The cause is not a single instruction suppressing progress text. It is that
templates/agent/tool_contract.md contains two adjacent rules that contradict each other,
and sendProgress output sits between them:
line 10 - When tools are needed before answering, do not include the final answer
with the tool calls. Wait for the tool results, then answer once.
line 13 - For multi-step tasks, outline the plan briefly and then execute it.The assistant message that carries tool calls is the only place AgentProgressHook reads
progress text from. Line 13 tells the model to outline its plan there; line 10 tells it to
write nothing there.
The observed shape is exactly how a model resolves that contradiction: one line at the start of a turn, then silence. For the user, a multi-step task is still a long blank screen.
1 · The delivery path works
sendProgress gates delivery of progress (channels/manager.py:803-810):
if progress_event:
if progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=True,
):
continue
if not progress_event.tool_hint and not self._should_send_progress(
msg.channel, tool_hint=False,
):
continueThe switch is resolved in manager.py:347 _should_send_progress().
The only source of progress text is agent/progress_hook.py:132:
async def before_execute_tools(self, context: AgentHookContext) -> None:
if self._publish:
if not self._streaming and not context.streamed_content:
thought = self._strip_think(context.response.content if context.response else None) # :135
if thought:
await self._publish(ProgressEvent(content=thought)) # :137So the whole feature depends on the model writing body text on the message that carries its tool calls. No text, no event to deliver.
2 · That same field is simultaneously told to be written and to be empty
Line 10: don't write
templates/agent/tool_contract.md:10 tells the model to hold the final answer until after
the tool results. Added by @chengyongru in b189a376 (#4945, 2026-07-22) to suppress the
#705 / #825 class of interim text.
Line 13: do write
templates/agent/tool_contract.md:13:
- For multi-step tasks, outline the plan briefly and then execute it. Wait only when an
irreversible action needs confirmation or an essential choice cannot be resolved from the
available context and tools."Outline the plan" can only land in the body of the message that carries the tool calls -- the exact field line 10 wants empty. And "and then execute it" scopes that outline to one, not one per stage.
Measured: the contradiction shows up as "one opener, then silence"
On a clean workspace (nanobot factory templates, no custom SOUL.md, no progress rule,
sendProgress left at its default true), running multi-step tasks and recording per
checkpoint (an assistant message carrying tool calls) whether the body was non-empty:
task 1 run 1: 3 checkpoints, non-empty at [0]
task 3 run 2: 6 checkpoints, non-empty at [0]
task 4 run 1: 22 checkpoints, non-empty at [0]
task 4 run 3: 12 checkpoints, non-empty at [0]In a 22-checkpoint turn, only the first one has any text. Aggregate: first checkpoint 10/12 non-empty, later checkpoints 1/66.
That matches "line 13 wins at the start, line 10 wins afterwards" -- not "the model never writes". Attributing the root cause to line 10 alone is inaccurate: if that were the whole story, the first checkpoint would be empty too.
3 · Production evidence: zero genuine progress lines
A real deployment running since 2026-09-23 (QQ channel, sendProgress=true throughout),
counted per checkpoint from its session records:
| date | checkpoints | progress lines | rate | note |
|---|---|---|---|---|
| 2026-09-24 | 45 | 0 | 0% | two non-empty bodies existed, but both were English deliberation — reasoning spilling into the content channel — and not progress lines |
| 2026-09-25 | 55 | 11 | 20.0% | after the user wrote their own progress rule into their profile |
| 2026-10-01 | 65 | 14 | 21.5% | same |
In other words: before the user worked around it themselves, this default-on feature produced zero genuine progress lines. The pre-workaround column is not "a low rate" — it is zero, once the two leaked bodies are discounted.
For contrast, that deployment runs channels.sendToolHints=false (the user turned tool
hints off). sendToolHints is not a substitute anyway -- when on, the user sees
read_file(foo.py), which conveys no intent or plan.
4 · Users have to reverse-engineer an internal hook into a profile rule
To get any progress at all, that deployment had to encode the rule in its SOUL.md:
Channel messages (hard rule, set by the user 2026-09-25, restated 2026-10-01 because I dropped it): when a turn will call tools several times in a row, within the same message as each stage's tool calls write one short Chinese progress line first ("reading the remote claude config", "running the smoke test"), then make that stage's tool calls. Mechanism:
AgentProgressHook.before_execute_toolspublishes that message's text as a standaloneProgressEventto the chat when not streaming; tool calls and their output are not shown -- text written after the tool calls, or in a separate turn, never gets through. When it is dropped, the user sees a long blank screen, i.e. they lose contact.
Three things stand out:
- The rule has to restate implementation details (
before_execute_tools, non-streaming,ProgressEvent) -- the behaviour is not discoverable from any configuration surface. - It had to be restated once ("2026-10-01 because I dropped it"), because a rule in a profile is only a soft constraint.
- It asks for "each stage", while
tool_contract.md:13grants one outline per turn -- which is why the two produce such different output.
A user profile also cannot be the upstream fix: it is per-deployment, per-language, and version-uncontrolled.
5 · Why it looks "on" but does nothing
| what the user sees in a multi-step task | |
|---|---|
sendProgress=true (default) | one opener, then silence |
sendProgress=false | silence throughout |
The only difference is that opening line -- and that line is what tool_contract.md:13
already asks for, independently of sendProgress. Turning the switch off does not stop
line 13 from asking for an outline; it only stops that outline being delivered.
So the switch's only observable effect appears after a workaround is already in place.
Proposed fix
(a) Default sendProgress to false
The current default promises something a default install cannot produce. Until (b) lands, the honest default is off; once (b) lands, opting in gives a setting that means what it says.
This is a behaviour change for all users (default value; opting in changes model behaviour). Stated explicitly here for review.
(b) When sendProgress is true, append a "progress authorisation" section to the system prompt
Text:
## Progress Notes
When a turn calls tools several times in a row, write one short progress line in the
same message as that stage's tool calls, then make the calls. Keep it brief, in the
user's language — for example "reading the deploy config" or "running the smoke test".
- Text written after the tool calls, or in a separate turn, never reaches the user.
When the line is missing, the user sees a long blank screen.
- This is not an answer. Never put results, conclusions, or the final answer in a
progress note — those belong in the final response.Four choices in the wording are deliberate:
- "that stage's tool calls" rather than "the message" — the target is one line per stage, not one opener.
- Stating the consequence ("the user sees a long blank screen") — turns a formatting request into something with a cost.
- Dropping "Skip the note when the next step needs no explanation", which an earlier draft carried — it is a counter-instruction that hands the model an escape hatch it will almost always take.
- Keeping "This is not an answer..." — that line is the brake for the #705 / #825 class of interim text, and it must not be displaced by the new section.
⚠️ One decision we are not making for you: how this relates to tool_contract.md:13
The new section says one line per stage ("in the same message as that stage's tool calls"); line 13 says "outline the plan briefly and then execute it" -- once. Those are in tension.
Two options, and the maintainer's call:
- A. The new section merely refines. It states that it expands line 13's "outline" into
"once per batch", and
tool_contract.mdstays byte-identical (smallest diff, but now two places jointly define one behaviour). - B. Adjust line 13 too (e.g. "outline each step briefly before executing it"), making the contract internally consistent, with the new section only declaring that this channel wants progress at all.
We lean B: the contradiction lives in tool_contract.md itself, and adding a section is
adding a third voice to a contract that already disagrees with itself. A section that only
refines leaves line 13's "once" in force, so the model still settles on a single opener.
tool_contract.md:10 stays untouched either way: it constrains the final answer, does
not conflict with a progress line, and is the brake installed for the #705 / #825 class.
Why gate on sendProgress rather than adding a switch
Generation should follow delivery. A separate "write progress?" switch creates a
sendProgress=true + "no generation" combination -- the very state this issue reports, just
hidden one level deeper.
- one switch, one meaning ("show progress text"), honoured by both halves
- no new config surface
falseis quiet end to end;trueboth produces and delivers
Scope / non-goals
- No new config keys.
tool_contract.md:10unchanged (see above).- Tool-hint behaviour unchanged (
sendToolHintsunaffected). - Does not address reasoning text spilling into the content channel -- a separate phenomenon whose trigger we could not reproduce; out of scope here.
Verification
The accompanying PR implements (a) and (b), with tests covering:
- the gating logic (no injection when
send_progressis false) - the fact that the prompt is otherwise unchanged (if option A is chosen)
- the regression classes this area has produced historically (#705 / #825 / #878)
The measurement harness used for §2 and §3 is tooling kept outside this repository, so the PR diff stays reviewable. Say the word and I will attach it, or reproduce any of the numbers above on request.
Activity
0No activity yet.