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,
):
continue
The 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)) # :137
So 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_tools publishes that message's text as a standalone
ProgressEvent to 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:13 grants 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:
⚠️ 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.md stays 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
false is quiet end to end; true both produces and delivers
Scope / non-goals
- No new config keys.
tool_contract.md:10 unchanged (see above).
- Tool-hint behaviour unchanged (
sendToolHints unaffected).
- 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 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.
Summary
channels.sendProgressdefaults totrue(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.mdcontains two adjacent rules that contradict each other,and
sendProgressoutput sits between them:The assistant message that carries tool calls is the only place
AgentProgressHookreadsprogress 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
sendProgressgates delivery of progress (channels/manager.py:803-810):The switch is resolved in
manager.py:347_should_send_progress().The only source of progress text is
agent/progress_hook.py:132:So 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:10tells the model to hold the final answer until afterthe 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:"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,sendProgressleft at its defaulttrue), running multi-step tasks and recording percheckpoint (an assistant message carrying tool calls) whether the body was non-empty:
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=truethroughout),counted per checkpoint from its session records:
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 toolhints off).
sendToolHintsis not a substitute anyway -- when on, the user seesread_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:Three things stand out:
before_execute_tools, non-streaming,ProgressEvent) -- the behaviour is not discoverable from any configuration surface.profile is only a soft constraint.
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
sendProgress=true(default)sendProgress=falseThe only difference is that opening line -- and that line is what
tool_contract.md:13already asks for, independently of
sendProgress. Turning the switch off does not stopline 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
sendProgresstofalseThe 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
sendProgressis true, append a "progress authorisation" section to the system promptText:
Four choices in the wording are deliberate:
stage, not one opener.
request into something with a cost.
draft carried — it is a counter-instruction that hands the model an escape hatch it will
almost always take.
of interim text, and it must not be displaced by the new section.
tool_contract.md:13The 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:
"once per batch", and
tool_contract.mdstays byte-identical (smallest diff, but now twoplaces jointly define one behaviour).
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.mditself, and adding a section isadding 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:10stays untouched either way: it constrains the final answer, doesnot conflict with a progress line, and is the brake installed for the #705 / #825 class.
Why gate on
sendProgressrather than adding a switchGeneration should follow delivery. A separate "write progress?" switch creates a
sendProgress=true+ "no generation" combination -- the very state this issue reports, justhidden one level deeper.
falseis quiet end to end;trueboth produces and deliversScope / non-goals
tool_contract.md:10unchanged (see above).sendToolHintsunaffected).whose trigger we could not reproduce; out of scope here.
Verification
The accompanying PR implements (a) and (b), with tests covering:
send_progressis false)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.