Skip to content

sendProgress: true yields at most one line per turn — tool_contract.md contradicts itself #6000

Description

@GZY-SUPER-HACKER

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions