Hmbown/CodeWhale · error · anyhow::Error

Agent Mail accepts a bounded handoff summary, not a raw tran

Error message

Agent Mail accepts a bounded handoff summary, not a raw transcript

What it means

queue_agent_mail rejected the request because agent_mail_looks_like_raw_transcript matched the summary: markers such as '<turn_meta>', '<assistant', '<tool_result', '"messages":', '"role":"assistant"', or lines starting with 'assistant:' / 'system:' / 'tool:' / 'tool_result:'. Agent Mail is a typed, bounded handoff mechanism - the summary must be a distilled handoff (max 2048 bytes), not a pasted conversation transcript.

Source

Thrown at crates/tui/src/runtime_threads.rs:3593

    /// Remove the goal for a thread. Returns `true` if a goal existed.
    pub async fn remove_goal(&self, thread_id: &str) -> Result<bool> {
        let thread_id = thread_id.to_string();
        let store = self.store.clone();
        tokio::task::spawn_blocking(move || store.delete_goal(&thread_id))
            .await
            .context("goal delete task panicked")?
    }

    /// Persist one canonical Agent Mail envelope in the runtime store. The
    /// caller-supplied id is an idempotency key: an exact replay returns the
    /// existing lifecycle record, while conflicting intent fails closed.
    pub async fn queue_agent_mail(
        &self,
        mut request: AgentMailSendRequest,
    ) -> Result<AgentMailSendResponse> {
        if agent_mail_looks_like_raw_transcript(&request.summary) {
            bail!("Agent Mail accepts a bounded handoff summary, not a raw transcript");
        }
        request.summary = sanitize_agent_mail_text(&request.summary, MAX_AGENT_MAIL_SUMMARY_BYTES);
        request.sender.display_label = sanitize_agent_mail_text(
            &request.sender.display_label,
            codewhale_protocol::agent_mail::MAX_AGENT_MAIL_DISPLAY_LABEL_BYTES,
        );
        for evidence in &mut request.evidence {
            if let Some(label) = evidence.label.as_mut() {
                *label = sanitize_agent_mail_text(
                    label,
                    codewhale_protocol::agent_mail::MAX_AGENT_MAIL_EVIDENCE_LABEL_BYTES,
                );
            }
        }
        request.validate().map_err(|error| anyhow!(error))?;
        if request.source_thread_id == request.destination_thread_id {
            bail!("Agent Mail source and destination threads must differ");
        }

View on GitHub (pinned to 0c42157ee5)

Solutions

  1. Summarize the handoff in prose: goal, state, next action - no transcript markers
  2. If the text genuinely must mention 'assistant:' etc., rephrase the line so it does not start with the role prefix (the check is line-prefix based)
  3. Move raw context into evidence references (evidence refs with bounded labels) instead of the summary body
  4. Keep the summary under MAX_AGENT_MAIL_SUMMARY_BYTES (2048) so sanitize/truncate does not distort it

Example fix

// before
request.summary = serde_json::to_string(&messages)?; // contains "messages": / "role":"assistant"

// after
request.summary = "Task: fix login bug. Auth flow traced; suspect token refresh. Next: add regression test for expired refresh_token.".to_string();
Defensive patterns

Strategy: validation

Validate before calling

// Reject transcript-shaped summaries before calling the API.
fn looks_like_transcript(summary: &str) -> bool {
    let lower = summary.to_ascii_lowercase();
    ["<turn_meta>", "<assistant", "<tool_result", "\"messages\":", "\"role\":\"assistant\"", "\"role\": \"assistant\""]
        .iter().any(|m| lower.contains(m))
        || lower.lines().any(|l| {
            let l = l.trim_start();
            l.starts_with("assistant:") || l.starts_with("system:")
                || l.starts_with("tool:") || l.starts_with("tool_result:")
        })
}
if looks_like_transcript(&request.summary) {
    return Err(anyhow::anyhow!("summary must be a distilled handoff, not a transcript"));
}

Try / catch

// Cheap catch with rephrase guidance.
match manager.queue_agent_mail(request).await {
    Ok(resp) => Ok(resp),
    Err(e) if e.to_string().contains("bounded handoff summary") => {
        Err(anyhow::anyhow!("agent mail summary rejected: rewrite as a short prose handoff"))
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: Calling queue_agent_mail with a summary built by serializing the message history (serde_json of a messages array) or concatenating transcript lines with role prefixes. Heuristic at runtime_threads.rs:406-428; check at :3592-3594, before sanitization and validate().

Common situations: An agent prompted to 'hand off context' pastes its transcript; a wrapper forwards raw provider payloads; a summary legitimately starting with a line like 'system: ...' trips the prefix detector.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@0c42157ee5 (2026-08-20). Data as JSON: /api/errors/c90fa28e722860bb. Report an issue: GitHub.