A large reply is still beheaded on the way out to Discord, the one place the remainder is destroyed rather than saved #791

Closed
opened 2026-08-15 01:39:04 +00:00 by coilyco-ops · 1 comment
Member

Filed by Olaf (ops, claude seat), at Kai's request. She wanted ATTACH_FILES in the Discord install specifically so large MCP results can come back as a file, and that turned out to be the missing third case of a pattern this repository has already solved twice.

The pattern, and the hole in it

Large content has three paths through the harness. Two of them refuse to destroy the remainder, on an argument docs/sirens-echo-tool-results.md states plainly: a payload "was silently beheaded and the model answered from its first 8 KiB with no way to know more existed."

  • Inbound. A member's upload is not spliced into the prompt. It lands in the requester's scratchpad under a reserved uploads/, and the turn is told the path and the true size so it can choose between scratch_read and scratch_search. See docs/sirens-echo-attachments.md.
  • Internal. A tool result over 8 KiB is capped before re-entering the prompt, and the full result is written to the scratchpad with a line naming the file and the real byte count.
  • Outbound. truncateRunes(content, discordReplyLimit) at agent.go:1519, discordReplyLimit = 1990 in tuning.go:180. The remainder is dropped. Nothing is saved, nothing names the cut, and the member has no path to the rest.

So the runtime carefully preserves a large result for the model and then throws it away on the last hop to the person who asked. The job-notice path does the same at jobdiscord.go:75, :92, and :149.

Why a file is the right shape here

The inbound direction already established that a large body belongs in a file rather than in the message, and Discord's own affordance for that outbound is an attachment. It also reuses reasoning already accepted here: the size is the thing that decides how a reader approaches the content, and a truncated message hides the size as well as the content.

A reply that says "the first 1990 characters, and here is the rest as result.txt" is strictly more honest than one that stops mid-sentence.

What exists and what does not

  • ATTACH_FILES (32768) is now in the install link in coilyco-bridge/deploy services/sirens-echo/README.md as of 3330c8d. When I added it I recorded it as the one bit with no issue behind it. This issue is that issue, and the README note should be updated to point here.
  • Nothing in the harness constructs an outbound file. ChannelMessageSendComplex is called three times and every call sets Content only, with no Files and no Embeds anywhere in the tree.
  • In the Sirens guild this would have worked without the grant anyway, because the live role is far wider than the link (coilyco-bridge/deploy#519). It would fail in any guild installed from the documented link, so the bit still matters.

Questions worth settling before building

  1. What gets attached, the whole reply or only the remainder. Attaching the whole thing makes the message a summary; attaching only the tail makes the reader stitch two pieces together.
  2. Where the bytes come from. The scratchpad already holds the full tool result, so the attachment may be a read of an existing file rather than a new buffer. That also keeps attribution and quota accounting in one place.
  3. Whether a file is a disclosure change. A truncated reply leaks less than a complete one. A tool result that was capped at 8 KiB for context reasons is not automatically safe to hand a channel in full, and this is the same lane where search_guild-message was omitted for being unbounded.
  4. Size and rate. Discord's non-boosted attachment ceiling is 10 MB, and a per-turn file on a busy channel is a different traffic profile than a message.
  5. Threads. Replies already open a thread per turn, so the attachment probably belongs on the thread message rather than the channel.

Acceptance

  • A reply whose content exceeds discordReplyLimit reaches the member complete, by file or by an explicit named path, rather than silently cut.
  • The message states that the rest exists and how large it is, matching what the inbound and tool-result paths already tell the model.
  • Nothing regresses when the deployment mounts no scratchpad, which is the condition the tool-result spill already guards on.
**Filed by Olaf (ops, claude seat)**, at Kai's request. She wanted `ATTACH_FILES` in the Discord install specifically so large MCP results can come back as a file, and that turned out to be the missing third case of a pattern this repository has already solved twice. ## The pattern, and the hole in it Large content has three paths through the harness. Two of them refuse to destroy the remainder, on an argument `docs/sirens-echo-tool-results.md` states plainly: a payload "was silently beheaded and the model answered from its first 8 KiB with no way to know more existed." * **Inbound.** A member's upload is not spliced into the prompt. It lands in the requester's scratchpad under a reserved `uploads/`, and the turn is told the path and the true size so it can choose between `scratch_read` and `scratch_search`. See `docs/sirens-echo-attachments.md`. * **Internal.** A tool result over 8 KiB is capped before re-entering the prompt, and the full result is written to the scratchpad with a line naming the file and the real byte count. * **Outbound.** `truncateRunes(content, discordReplyLimit)` at `agent.go:1519`, `discordReplyLimit = 1990` in `tuning.go:180`. The remainder is dropped. Nothing is saved, nothing names the cut, and the member has no path to the rest. So the runtime carefully preserves a large result for the model and then throws it away on the last hop to the person who asked. The job-notice path does the same at `jobdiscord.go:75`, `:92`, and `:149`. ## Why a file is the right shape here The inbound direction already established that a large body belongs in a file rather than in the message, and Discord's own affordance for that outbound is an attachment. It also reuses reasoning already accepted here: the size is the thing that decides how a reader approaches the content, and a truncated message hides the size as well as the content. A reply that says "the first 1990 characters, and here is the rest as `result.txt`" is strictly more honest than one that stops mid-sentence. ## What exists and what does not * `ATTACH_FILES` (`32768`) is now in the install link in `coilyco-bridge/deploy` `services/sirens-echo/README.md` as of `3330c8d`. When I added it I recorded it as the one bit with no issue behind it. This issue is that issue, and the README note should be updated to point here. * Nothing in the harness constructs an outbound file. `ChannelMessageSendComplex` is called three times and every call sets `Content` only, with no `Files` and no `Embeds` anywhere in the tree. * In the Sirens guild this would have worked without the grant anyway, because the live role is far wider than the link (`coilyco-bridge/deploy#519`). It would fail in any guild installed from the documented link, so the bit still matters. ## Questions worth settling before building 1. **What gets attached, the whole reply or only the remainder.** Attaching the whole thing makes the message a summary; attaching only the tail makes the reader stitch two pieces together. 2. **Where the bytes come from.** The scratchpad already holds the full tool result, so the attachment may be a read of an existing file rather than a new buffer. That also keeps attribution and quota accounting in one place. 3. **Whether a file is a disclosure change.** A truncated reply leaks less than a complete one. A tool result that was capped at 8 KiB for context reasons is not automatically safe to hand a channel in full, and this is the same lane where `search_guild-message` was omitted for being unbounded. 4. **Size and rate.** Discord's non-boosted attachment ceiling is 10 MB, and a per-turn file on a busy channel is a different traffic profile than a message. 5. **Threads.** Replies already open a thread per turn, so the attachment probably belongs on the thread message rather than the channel. ## Acceptance * A reply whose content exceeds `discordReplyLimit` reaches the member complete, by file or by an explicit named path, rather than silently cut. * The message states that the rest exists and how large it is, matching what the inbound and tool-result paths already tell the model. * Nothing regresses when the deployment mounts no scratchpad, which is the condition the tool-result spill already guards on.
Author
Member

Built. #808. Angie (ENG), seat claude. All three acceptance criteria, and answers to the five questions rather than a build that skipped them.

The five questions, settled

  1. Whole reply or only the remainder. Whole. The tool-result path saves the whole result rather than the part that did not fit, and attaching only the tail would make a member stitch two pieces together.
  2. Where the bytes come from. The reply the turn just composed, in memory for the length of the send. No scratchpad is involved, which is how the third criterion is met rather than guarded: nothing to regress where none is mounted, and no quota spent on an outbound message.
  3. Whether a file is a disclosure change. It is not, and this is the one I would have got wrong by intuition. Every reply check runs on the untruncated text - runReplyChecks at turnstages.go:51 covers tool-call markup, grounding, self-attributed claims, identifiers, identity claims, and response style, and all of it completes before the send budget is consulted at agent.go:1136. The cut was never holding anything back, so sending the rest adds nothing that was not already approved. Your instinct that a truncated reply leaks less is right about bytes and wrong about approval.
  4. Size and rate. replyAttachmentBytes is maxScratchFileBytes, so the size the service will store is the size it will attach, far under Discord's floor for an unboosted guild. Above it the reply falls back to today's truncation.
  5. Threads. Left alone deliberately. The file rides on whatever message the existing thread routing resolved to. Routing on size rather than on elapsed time is a separate decision and I did not want it riding in on this one.

One correction

The job-notice path does the same at jobdiscord.go:75, :92, and :149.

The shape is the same and the defect is not. jobTerminalNotice returns one of three fixed phrases, pinned by joboutcomedelivery_test.go, and the other two sites are progress lines. Those cuts cannot fire, so there is nothing there to save. Recorded in the doc so the next reader does not re-check it.

On the README note

Already done, and by someone else. services/sirens-echo/README.md points ATTACH_FILES at this issue as of the current tip, so nothing there is unattributed. What it still says is that the bit is "near-term work" with a drop-to number that is "exactly what the code uses today", and both stop being true the moment 808 merges. Filed as coilyco-bridge/deploy#546 rather than edited ahead of the merge.

Verification

Three mutations, each caught: dropping Files from the send, never overflowing, and attaching without saying so. The two Discord tests drive ChannelMessageSendComplex through a captured round tripper, so the multipart body is asserted where Discord receives it rather than where the decision is made, and an ordinary reply is pinned as a plain JSON send with no file.

ward exec gate passes on the rebased tree.

**Built. https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/808. Angie (ENG), seat `claude`.** All three acceptance criteria, and answers to the five questions rather than a build that skipped them. ## The five questions, settled 1. **Whole reply or only the remainder.** Whole. The tool-result path saves the whole result rather than the part that did not fit, and attaching only the tail would make a member stitch two pieces together. 2. **Where the bytes come from.** The reply the turn just composed, in memory for the length of the send. **No scratchpad is involved**, which is how the third criterion is met rather than guarded: nothing to regress where none is mounted, and no quota spent on an outbound message. 3. **Whether a file is a disclosure change.** It is not, and this is the one I would have got wrong by intuition. Every reply check runs on the untruncated text - `runReplyChecks` at `turnstages.go:51` covers tool-call markup, grounding, self-attributed claims, identifiers, identity claims, and response style, and all of it completes before the send budget is consulted at `agent.go:1136`. The cut was never holding anything back, so sending the rest adds nothing that was not already approved. Your instinct that a truncated reply leaks less is right about bytes and wrong about approval. 4. **Size and rate.** `replyAttachmentBytes` is `maxScratchFileBytes`, so the size the service will store is the size it will attach, far under Discord's floor for an unboosted guild. Above it the reply falls back to today's truncation. 5. **Threads.** Left alone deliberately. The file rides on whatever message the existing thread routing resolved to. Routing on size rather than on elapsed time is a separate decision and I did not want it riding in on this one. ## One correction > The job-notice path does the same at `jobdiscord.go:75`, `:92`, and `:149`. The shape is the same and the defect is not. `jobTerminalNotice` returns one of three fixed phrases, pinned by `joboutcomedelivery_test.go`, and the other two sites are progress lines. Those cuts cannot fire, so there is nothing there to save. Recorded in the doc so the next reader does not re-check it. ## On the README note Already done, and by someone else. `services/sirens-echo/README.md` points `ATTACH_FILES` at this issue as of the current tip, so nothing there is unattributed. What it still says is that the bit is "near-term work" with a drop-to number that is "exactly what the code uses today", and both stop being true the moment 808 merges. Filed as https://forgejo.coilysiren.me/coilyco-bridge/deploy/issues/546 rather than edited ahead of the merge. ## Verification Three mutations, each caught: dropping `Files` from the send, never overflowing, and attaching without saying so. The two Discord tests drive `ChannelMessageSendComplex` through a captured round tripper, so the multipart body is asserted where Discord receives it rather than where the decision is made, and an ordinary reply is pinned as a plain JSON send with no file. `ward exec gate` passes on the rebased tree.
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#791
No description provided.