Skip to content

feat(agent): Announce-then-stop system block - #357

Merged
shayne-snap merged 4 commits into
usewhale:mainfrom
reneleonhardt:fix/announce-stop-reasoning-replay
Aug 8, 2026
Merged

feat(agent): Announce-then-stop system block#357
shayne-snap merged 4 commits into
usewhale:mainfrom
reneleonhardt:fix/announce-stop-reasoning-replay

Conversation

@reneleonhardt

@reneleonhardt reneleonhardt commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The model frequently ends a turn by writing a status + next-steps ("…then commit", "…inspect + resolve", "…amend tests into commit") followed by end_turn with zero tool calls.
This is model behavior, not a dropped turn — full-size completions with no retry signature. A static system instruction plus a deterministic turn-loop guard stop it repo-wide.

Changes

  • One immutable system block in buildImmutableSystemBlocksWithTools (internal/agent/system_prompt.go), appended unconditionally alongside the mode contract / tool policy / delegation blocks:

    Execute, don't narrate: never end a turn by announcing further work — do it now, in this turn, with tool calls when the work requires an available tool, otherwise finish directly. A turn ends only when the user explicitly asked for it (e.g. "stop", "summarize", "hand off"), the task is genuinely complete, or you need user input.

  • Premature-end recovery guard widened in internal/agent/premature_end_turn.go: the turn-loop guard that nudges an announce-then-stop shape (Agent mode, end_turn, no tool calls) now recovers on a trailing colon alone. It previously required colon and an imperative verb-prefix on the lowercased trailing clause, which gerund lead-ins such as "Fixing:" / "Running tests:" slipped past. The verb-prefix scan stays as the non-colon fallback and now also matches inflected -ing gerund forms (fixing, running, updating, writing), covering the one known "."-terminated shape plus non-colon gerund lead-ins. Outer gates unchanged: Agent mode only, tools available, SuppressTools off, end_turn, no tool calls, maxPrematureEndTurnNudges = 2 (any false positive costs one bounded extra model call, not a loop).
  • Covers every entrypoint from one place: the block lives in the shared turn loop (turn_loop.gobuildImmutableSystemBlocksWithTools), so it reaches ACP (cmd/whale-acp), the terminal CLI (internal/app runtime), and subagents (internal/tasks) — no ACP-only wiring, which would have missed the terminal and subagent paths.

Why in internal/agent, not internal/acp

A deep-codemap trace showed the ACP-only placement (WithExtraSystemBlocks in whale-acp main) would fix only the Zed/ACP path. The terminal whale CLI and subagents build their own agents; all three route through the same turn loop, so the static block there is the single correct chokepoint.

Validation

  • go build ./..., go vet, gofmt clean.
  • New test pins: block present exactly once; present across all three session modes (agent/ask/plan); absent from runtime blocks; independent of tool registry.
  • Guard regression table for shouldRecoverPrematureEndTurn: gerund colon lead-ins (Fixing:, Running tests:), then commit: / Inspect + resolve keeping both:, the "."-terminated fallback, mode/tool gates (Plan/Ask, SuppressTools, no tools, non-end_turn finishes, turns with tool calls), accepted colon false positives (fixture analysis, headings, user-choice prompt), plus direct trailingActionClause unit coverage. go test -race ./internal/agent -run PrematureEnd green; full -race agent suite green.

Out of scope

The reasoning-replay half of the source feature (upstream PR #353, "prevent stale response history replay") is already in main — no code change needed here.

User-visible impact

  • Agent keeps executing after a status/next-steps message instead of stopping to await a non-existent prompt.
  • A turn ending at a dangling colon lead-in now gets one bounded nudge to continue or finish instead of silently stopping.
  • Applies to whale-acp sessions, whale terminals, and subagents alike.

Breaking changes

None. Adds one system-prompt block and widens an existing recovery predicate.

Notes for reviewers

  • system_prompt.go is a leaf (no importers); the block is a plain static append — zero structural change, no shared mutable state, no race surface.
  • The guard widening accepts colon-terminated false positives (e.g. headings, user-choice prompts) deliberately: a colon-terminated final answer is near-nonsense, the nudge text tells the model to provide a complete answer if no action remains, and the 2-nudge cap bounds the cost.
  • Homebrew whale 0.1.65 binaries don't have the block; it reaches terminal users only once a fork-built whale CLI is installed.

Developed with carefully directed, manually reviewed AI assistance.

Comment thread internal/agent/system_prompt.go Outdated
systemBlocks = append(systemBlocks, "For questions about the current date or time, use an available read-only shell/time command to verify the answer instead of guessing from model memory.")
systemBlocks = append(systemBlocks, renderToolPolicyBlock())
systemBlocks = append(systemBlocks, "For branch decisions or key assumptions requiring user choice, call request_user_input instead of presenting long A/B/C prose menus.")
systemBlocks = append(systemBlocks, "Execute, don't narrate: never end a turn by announcing further work — do it now, in this turn, with tool calls. A turn ends only when the user explicitly asked for it (e.g. \"stop\", \"summarize\", \"hand off\"), the task is genuinely complete, or you need user input. Otherwise keep going automatically — planned-but-unexecuted next steps are a failure, not a completion.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is also injected into agents with an empty tool registry, and the test below explicitly pins it across Agent, Ask, and Plan modes. Requiring the work to be done “with tool calls” therefore conflicts with valid tool-less/read-only executions: a model-only subagent may be told to issue a tool call that does not exist, while Ask/Plan are intentionally allowed to finish without one.\n\nCould we make this conditional, matching the existing premature-end recovery nudge? For example: “If the announced action requires an available tool, issue that tool call now. Otherwise complete the work directly and provide the final answer.” This keeps the announce-then-stop fix without imposing tool use where tools are unavailable or unnecessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course, can you design a complete instruction covering all cases and suggest it here?
Can you suggest tests for those tool-less or read-only executions so we can cover all possible cases?

@reneleonhardt reneleonhardt Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better and shorter now, please review if you find more edge cases.

- Execute, don't narrate: never end a turn by announcing further work — do it now, in this turn, with tool calls. A turn ends only when the user explicitly asked for it (e.g. "stop", "summarize", "hand off"), the task is genuinely complete, or you need user input. Otherwise keep going automatically — planned-but-unexecuted next steps are a failure, not a completion.
+ Execute, don't narrate: never end a turn by announcing further work — do it now, in this turn, with tool calls when the work requires an available tool, otherwise finish directly. A turn ends only when the user explicitly asked for it (e.g. "stop", "summarize", "hand off"), the task is genuinely complete, or you need user input.

Squash and merge when you didn't find any more improvements.
The new commit is only for reviewability.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What changed in the guard logic

The premature-end guard previously recovered a turn only when both conditions held: a trailing colon and an imperative verb-prefix on the trailing clause ("fix ", "run ", "then ", …). Inflected lead-ins like "Fixing:" or "Running tests:" failed the prefix match ("fix ""fixing") and slipped through, ending the turn with zero tool calls.

New predicate: recover = endsWithColon OR action-prefix match.

  • A trailing colon now triggers recovery on its own — 3 of 4 known announce-then-stop instances end in ":", including the inflected ones that used to miss. A colon-terminated final answer is near-nonsense anyway (it invites continuation), and the nudge's "if no action remains, provide the complete final answer" escape hatch plus the existing 2-nudge cap bound any false positive to one extra model call.
  • The verb-prefix scan is kept as the non-colon fallback and now also matches inflected -ing gerund forms (fixing, running, updating, writing, editing, checking, inspecting, verifying, executing, continuing, starting, retrying, rerunning, re-running) by exact enumeration — no stemming, no new false-positive surface beyond the intended inflected shapes. This covers the one known "."-terminated instance ("…then append maintainer reply draft.") and non-colon gerund lead-ins ("Fixing the permission check").

All outer gates unchanged: Agent mode only, SuppressTools off, tools available, end_turn, no tool calls, maxPrematureEndTurnNudges = 2. Strictly more permissive shape recognition; same push policy.

@reneleonhardt
reneleonhardt force-pushed the fix/announce-stop-reasoning-replay branch 3 times, most recently from 4781347 to 179c8ab Compare August 7, 2026 16:50
reneleonhardt and others added 4 commits August 7, 2026 19:09
The model frequently ended turns by writing a status + next-steps ("…then
commit", "…inspect + resolve") followed by end_turn with zero tool calls.
On the fixed binary this is model behavior, not a dropped turn — full-size
completions with no retry signature.

Add one static instruction to buildImmutableSystemBlocksWithTools so it
reaches every entrypoint that shares the turn loop: ACP (whale-acp),
terminal CLI (internal/app runtime), and subagents (internal/tasks) — the
block is appended unconditionally for all agents, so no entrypoint-specific
wiring is needed.

Instruction: execute, don't narrate — never end a turn by announcing further
work; a turn ends only on explicit user request, genuine completion, or a
needed user input; otherwise keep going automatically.

Reasoning-replay half of the feature (upstream PR usewhale#353, prevent stale
response history replay) was already landed in main via the rebase onto
6de6b6b — verified ancestor of this branch; no code change needed here.
Maintainer review: the block is injected into agents with an empty tool
registry (model-only subagents) and is pinned across Agent/Ask/Plan modes,
where finishing without a tool call is legitimate. Requiring the work to be
done "with tool calls" forced impossible tool use there.

Reword to "... with tool calls when the work requires an available tool,
otherwise finish directly." — keeps the announce-then-stop fix while
allowing tool-less and read-only executions.

Also drop the closing "Otherwise keep going automatically — planned-but-
unexecuted next steps are a failure" sentence: the first sentence ("do it
now, in this turn ... otherwise finish directly") plus "A turn ends only
when ..." already cover continuation to completion; the block is new to
this branch (main has none), so nothing regresses. Pinned test fragments
updated accordingly.

Co-authored-by: GPT-5.6 Sol <codex@openai.com>
… inflection

The premature-end guard required BOTH a trailing colon AND a verb-prefix
match on the lowercased trailing clause. Gerund lead-ins ("Fixing:",
"Running tests:") failed the "fix " prefix scan and slipped through:
a session ended at "…contradicts. Fixing:" with 0 tool calls.

Widen the predicate to recover = endsWithColon OR action-prefix match:
- The trailing colon is the empirically reliable signal (3 of 4 known
  announce-then-stop instances end in ":"), and a colon-terminated
  final answer is near-nonsense; the nudge's escape hatch plus the
  existing 2-nudge cap bound any false positive to one extra model call.
- The prefix list stays as the non-colon fallback so the one known
  "."-terminated instance ("…then append maintainer reply draft.")
  is still caught.
- Outer gates unchanged: Agent mode only, SuppressTools off, tools
  available, end_turn, no tool calls, maxPrematureEndTurnNudges = 2.

Tests: add the gerund colon case, then-commit, inspect+resolve, running
tests, dot-terminated fallback, accepted colon false positives (fixture
analysis, headings, user choice prompt), non-end-turn finish reasons,
tool-call-present, trailing-whitespace/empty/bare-colon text edges, and
direct trailingActionClause unit coverage. -race agent suite green.
The non-colon fallback only matched bare imperative prefixes ("fix ",
"run "), so non-colon gerund lead-ins ("Fixing the permission check",
"Running the tests") still slipped through — the same inflection class
the colon rule was added for. Add the -ing forms of the English action
prefixes by exact enumeration (fixing, running, updating, writing,
editing, checking, inspecting, verifying, executing, continuing,
starting, retrying, rerunning, re-running); no stemming, so no new
false-positive surface beyond the intended inflected shapes. Colon rule
and outer gates unchanged.

Tests: non-colon gerund fallback cases (fixing/running/updating/writing/
retrying) plus a non-action gerund negative ("singing"). -race green.
@reneleonhardt
reneleonhardt force-pushed the fix/announce-stop-reasoning-replay branch from 179c8ab to 2e1648f Compare August 7, 2026 17:09
@shayne-snap
shayne-snap merged commit f6661d9 into usewhale:main Aug 8, 2026
2 checks passed
@reneleonhardt
reneleonhardt deleted the fix/announce-stop-reasoning-replay branch August 8, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants