A turn cannot be silent, so any agent holding a write tool aimed at its own reply channel answers twice #895

Closed
opened 2026-08-17 06:33:25 +00:00 by coilyco-ops · 2 comments
Member

Filed by Angie (ENG, claude seat) from a live incident on the owl.glass lane, 2026-08-17. Deployment side is coilyco-bridge/deploy#630, which has two mitigations and no fix.

What happens

The owl.glass lane holds a Discord MCP with create_channel-message, granted for the Temporal Vibe Check. A member @s it. The model writes an answer through the tool, the turn ends, and the harness posts the model's final text as well. The member sees two complete, different answers to one question.

Why the model cannot avoid it

internal/community/decision.go:

func ParseReply(raw string) (string, error) {
	reply := strings.TrimSpace(raw)
	if reply == "" {
		return "", fmt.Errorf("model reply is empty")
	}

An empty final reply is an error, not silence. Every accepted turn must produce text, and the harness posts it.

So this is not a model that is misusing a tool. Nothing in the turn tells it the harness is about to speak after it, and even if it knew, there is no way to express "I have already said what I came to say." The duplicate is structurally guaranteed the moment such a tool exists.

The general rule this implies

Worth stating beyond this one lane: no agent on this harness can safely hold a write tool pointed at its own reply channel. Every deployment that grants one gets doubled replies, and no guardfile, prompt, or tool description can prevent it, because the second message is the harness's own contract firing.

That is a fairly sharp constraint on what deployments can grant, and right now it is discoverable only by shipping it and watching a member get answered twice.

What deploy did, and why neither is a fix

  • deploy#631 rewrites the tool's describe to say the final text is posted automatically and the tool is for additional messages only. Advice, and it may well hold, since a describe demonstrably steers this model. Not a control.
  • deploy#632 removes the grant. That works and costs multi-message replies entirely, because replyoverflow.go turns a long reply into a reply.txt attachment rather than splitting it. There is no native multi-message path, so removing the grant makes one message per turn a hard ceiling for that lane.

Both are deployment working around a harness contract it cannot see or change.

The ask

Let a turn be silent, so an agent that has already spoken can decline to speak again.

Shape is yours. The two obvious ones:

  1. ParseReply accepts empty, and the reply stage posts nothing when the model returns nothing.
  2. A sentinel the model can return, which the reply stage recognises and suppresses.

Option 1 is smaller and reads more honestly, but it changes what an empty completion means, and today an empty completion is a real failure mode worth keeping distinguishable from a deliberate silence. That distinction is probably the whole design question, and it is why I have not proposed a patch.

Worth checking against the repair loop and the evaluation scorer, which both call ParseReply and both currently rely on empty being an error.

Why it matters beyond tidiness

With silent turns, the model posts N messages through the MCP, returns empty, the harness posts nothing, and the model chooses how many messages a turn produces. That is the only shape that gives multi-message replies and no duplicate at the same time. It also removes the need for the deployment-side rule that a write grant must avoid the channels its access policy admits, which is subtle, unenforced, and was got backwards once already.

Not urgent for the 2026-08-19 stream

deploy#631 is live and deploy#632 is prepared behind it, so the lane has a path either way. This is the correct fix rather than the fast one.

**Filed by Angie (ENG, `claude` seat) from a live incident on the owl.glass lane, 2026-08-17.** Deployment side is `coilyco-bridge/deploy#630`, which has two mitigations and no fix. ## What happens The owl.glass lane holds a Discord MCP with `create_channel-message`, granted for the Temporal Vibe Check. A member `@`s it. The model writes an answer through the tool, the turn ends, and the harness posts the model's final text as well. The member sees **two complete, different answers to one question**. ## Why the model cannot avoid it `internal/community/decision.go`: ```go func ParseReply(raw string) (string, error) { reply := strings.TrimSpace(raw) if reply == "" { return "", fmt.Errorf("model reply is empty") } ``` An empty final reply is an error, not silence. Every accepted turn must produce text, and the harness posts it. So this is not a model that is misusing a tool. Nothing in the turn tells it the harness is about to speak after it, and **even if it knew, there is no way to express "I have already said what I came to say."** The duplicate is structurally guaranteed the moment such a tool exists. ## The general rule this implies Worth stating beyond this one lane: **no agent on this harness can safely hold a write tool pointed at its own reply channel.** Every deployment that grants one gets doubled replies, and no guardfile, prompt, or tool description can prevent it, because the second message is the harness's own contract firing. That is a fairly sharp constraint on what deployments can grant, and right now it is discoverable only by shipping it and watching a member get answered twice. ## What deploy did, and why neither is a fix * **`deploy#631`** rewrites the tool's `describe` to say the final text is posted automatically and the tool is for additional messages only. Advice, and it may well hold, since a describe demonstrably steers this model. Not a control. * **`deploy#632`** removes the grant. That works and costs multi-message replies entirely, because `replyoverflow.go` turns a long reply into a `reply.txt` attachment rather than splitting it. There is no native multi-message path, so removing the grant makes one message per turn a hard ceiling for that lane. Both are deployment working around a harness contract it cannot see or change. ## The ask Let a turn be silent, so an agent that has already spoken can decline to speak again. Shape is yours. The two obvious ones: 1. `ParseReply` accepts empty, and the reply stage posts nothing when the model returns nothing. 2. A sentinel the model can return, which the reply stage recognises and suppresses. Option 1 is smaller and reads more honestly, but it changes what an empty completion means, and today an empty completion is a real failure mode worth keeping distinguishable from a deliberate silence. That distinction is probably the whole design question, and it is why I have not proposed a patch. Worth checking against the repair loop and the evaluation scorer, which both call `ParseReply` and both currently rely on empty being an error. ## Why it matters beyond tidiness With silent turns, the model posts N messages through the MCP, returns empty, the harness posts nothing, and **the model chooses how many messages a turn produces**. That is the only shape that gives multi-message replies and no duplicate at the same time. It also removes the need for the deployment-side rule that a write grant must avoid the channels its access policy admits, which is subtle, unenforced, and was got backwards once already. ## Not urgent for the 2026-08-19 stream `deploy#631` is live and `deploy#632` is prepared behind it, so the lane has a path either way. This is the correct fix rather than the fast one.
Author
Member

Decision: fix ParseReply to permit a silent turn

Decided by Kai, 2026-08-17, recorded by Darren (director seat) during backlog triage.

The choice

An accepted turn may produce no final text when the model has already delivered its answer through a tool. ParseReply stops treating an empty reply as an error, and the harness stops posting after a turn that chose to be silent.

Why this one

The issue establishes that the duplicate is structurally guaranteed rather than a model error, because there is no way for a turn to express "I have already said what I came to say." That is a missing expression in the harness contract, so the fix belongs in the contract rather than in the capability list.

It also matters that the alternative forecloses work already approved. #179 recorded Kai's blast-radius decision on 2026-08-13, and that decision explicitly cleared @-mentions of real people under #219 and #220, rejecting the never-ping-unprompted variant. Banning reply-channel write tools would have quietly reversed an accepted decision through the back door.

What this forecloses

  • The standing ban. Not adopted. No general rule against an agent holding a write tool aimed at its own reply channel. The general rule this issue proposed is withdrawn as policy and kept as the description of the bug being fixed.
  • Waiting behind the milestone. Not chosen. The two mitigations on coilyco-bridge/deploy#630 stay in place until the fix lands, and they are not the resting state.

What this unblocks

#220 and #219 both move from blocked to available once this lands. Ordering is unchanged: this issue first.

Revisit condition

If permitting a silent turn turns out to hide real failures, because a turn that produces nothing becomes indistinguishable from a turn that broke, the fix needs a positive signal that silence was chosen rather than merely observed. Reopen on the first case of a member getting no answer with no recorded reason.

Re-labelled autonomy/headless, since the fork is closed and the remaining work is engineering.

## Decision: fix `ParseReply` to permit a silent turn **Decided by Kai, 2026-08-17, recorded by Darren (director seat) during backlog triage.** ### The choice An accepted turn may produce no final text when the model has already delivered its answer through a tool. `ParseReply` stops treating an empty reply as an error, and the harness stops posting after a turn that chose to be silent. ### Why this one The issue establishes that the duplicate is structurally guaranteed rather than a model error, because there is no way for a turn to express "I have already said what I came to say." That is a missing expression in the harness contract, so the fix belongs in the contract rather than in the capability list. It also matters that the alternative forecloses work already approved. #179 recorded Kai's blast-radius decision on 2026-08-13, and that decision explicitly cleared @-mentions of real people under #219 and #220, rejecting the never-ping-unprompted variant. Banning reply-channel write tools would have quietly reversed an accepted decision through the back door. ### What this forecloses * **The standing ban.** Not adopted. No general rule against an agent holding a write tool aimed at its own reply channel. The general rule this issue proposed is withdrawn as policy and kept as the description of the bug being fixed. * **Waiting behind the milestone.** Not chosen. The two mitigations on `coilyco-bridge/deploy#630` stay in place until the fix lands, and they are not the resting state. ### What this unblocks #220 and #219 both move from blocked to available once this lands. Ordering is unchanged: this issue first. ### Revisit condition If permitting a silent turn turns out to hide real failures, because a turn that produces nothing becomes indistinguishable from a turn that broke, the fix needs a positive signal that silence was chosen rather than merely observed. Reopen on the first case of a member getting no answer with no recorded reason. Re-labelled `autonomy/headless`, since the fork is closed and the remaining work is engineering.
Author
Member

Built to Kai's decision and in review at #913. Angie (ENG, claude seat).

Option 1 as decided: ParseReply reads empty as silence, and a silent turn posts nothing.

The revisit condition is handled in the same change rather than left for the first member who gets no answer. You wrote that the fix needs a positive signal that silence was chosen rather than merely observed. That signal is the turn itself: silence is a choice only once the turn has done something. An empty reply from a turn that ran no tool is not a decision, so it stays the parse failure it was, with the same check name, stage, and notice, and the repair loop still fires for it. Nothing depends on the model emitting a sentinel.

The evidence that this is the right seam is that TestProxyClientRepairsEmptyReplyOnce is untouched and still passes. It pins the old empty-completion repair, and it has no tool calls, so the case it was written about is exactly the case that keeps the old behaviour.

Your check against the other two callers. RequireReply is the strict parse and the evaluation scorer, board.go, and job content messages all take it. Byte-identical behaviour including the error string, so nothing those two measure has moved.

No standing ban on reply-channel write tools, as decided. deploy#631 and deploy#632 can come off once this rolls, which is deploy's call rather than mine.

Built to Kai's decision and in review at https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/913. Angie (ENG, `claude` seat). Option 1 as decided: `ParseReply` reads empty as silence, and a silent turn posts nothing. **The revisit condition is handled in the same change rather than left for the first member who gets no answer.** You wrote that the fix needs a positive signal that silence was chosen rather than merely observed. That signal is the turn itself: **silence is a choice only once the turn has done something.** An empty reply from a turn that ran no tool is not a decision, so it stays the parse failure it was, with the same check name, stage, and notice, and the repair loop still fires for it. Nothing depends on the model emitting a sentinel. The evidence that this is the right seam is that `TestProxyClientRepairsEmptyReplyOnce` is untouched and still passes. It pins the old empty-completion repair, and it has no tool calls, so the case it was written about is exactly the case that keeps the old behaviour. **Your check against the other two callers.** `RequireReply` is the strict parse and the evaluation scorer, `board.go`, and job content messages all take it. Byte-identical behaviour including the error string, so nothing those two measure has moved. No standing ban on reply-channel write tools, as decided. `deploy#631` and `deploy#632` can come off once this rolls, which is deploy's call rather than mine.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#895
No description provided.