{"record":{"id":"c90fa28e722860bb","repo":"Hmbown/CodeWhale","slug":"agent-mail-accepts-a-bounded-handoff-summary-not","errorCode":null,"errorMessage":"Agent Mail accepts a bounded handoff summary, not a raw transcript","messagePattern":"Agent Mail accepts a bounded handoff summary, not a raw transcript","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/tui/src/runtime_threads.rs","lineNumber":3593,"sourceCode":"\n    /// Remove the goal for a thread. Returns `true` if a goal existed.\n    pub async fn remove_goal(&self, thread_id: &str) -> Result<bool> {\n        let thread_id = thread_id.to_string();\n        let store = self.store.clone();\n        tokio::task::spawn_blocking(move || store.delete_goal(&thread_id))\n            .await\n            .context(\"goal delete task panicked\")?\n    }\n\n    /// Persist one canonical Agent Mail envelope in the runtime store. The\n    /// caller-supplied id is an idempotency key: an exact replay returns the\n    /// existing lifecycle record, while conflicting intent fails closed.\n    pub async fn queue_agent_mail(\n        &self,\n        mut request: AgentMailSendRequest,\n    ) -> Result<AgentMailSendResponse> {\n        if agent_mail_looks_like_raw_transcript(&request.summary) {\n            bail!(\"Agent Mail accepts a bounded handoff summary, not a raw transcript\");\n        }\n        request.summary = sanitize_agent_mail_text(&request.summary, MAX_AGENT_MAIL_SUMMARY_BYTES);\n        request.sender.display_label = sanitize_agent_mail_text(\n            &request.sender.display_label,\n            codewhale_protocol::agent_mail::MAX_AGENT_MAIL_DISPLAY_LABEL_BYTES,\n        );\n        for evidence in &mut request.evidence {\n            if let Some(label) = evidence.label.as_mut() {\n                *label = sanitize_agent_mail_text(\n                    label,\n                    codewhale_protocol::agent_mail::MAX_AGENT_MAIL_EVIDENCE_LABEL_BYTES,\n                );\n            }\n        }\n        request.validate().map_err(|error| anyhow!(error))?;\n        if request.source_thread_id == request.destination_thread_id {\n            bail!(\"Agent Mail source and destination threads must differ\");\n        }","sourceCodeStart":3575,"sourceCodeEnd":3611,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/0c42157ee52f9d55af2b506d71b46249910f77d3/crates/tui/src/runtime_threads.rs#L3575-L3611","documentation":"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.","triggerScenarios":"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().","commonSituations":"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.","solutions":["Summarize the handoff in prose: goal, state, next action - no transcript markers","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)","Move raw context into evidence references (evidence refs with bounded labels) instead of the summary body","Keep the summary under MAX_AGENT_MAIL_SUMMARY_BYTES (2048) so sanitize/truncate does not distort it"],"exampleFix":"// before\nrequest.summary = serde_json::to_string(&messages)?; // contains \"messages\": / \"role\":\"assistant\"\n\n// after\nrequest.summary = \"Task: fix login bug. Auth flow traced; suspect token refresh. Next: add regression test for expired refresh_token.\".to_string();","handlingStrategy":"validation","validationCode":"// Reject transcript-shaped summaries before calling the API.\nfn looks_like_transcript(summary: &str) -> bool {\n    let lower = summary.to_ascii_lowercase();\n    [\"<turn_meta>\", \"<assistant\", \"<tool_result\", \"\\\"messages\\\":\", \"\\\"role\\\":\\\"assistant\\\"\", \"\\\"role\\\": \\\"assistant\\\"\"]\n        .iter().any(|m| lower.contains(m))\n        || lower.lines().any(|l| {\n            let l = l.trim_start();\n            l.starts_with(\"assistant:\") || l.starts_with(\"system:\")\n                || l.starts_with(\"tool:\") || l.starts_with(\"tool_result:\")\n        })\n}\nif looks_like_transcript(&request.summary) {\n    return Err(anyhow::anyhow!(\"summary must be a distilled handoff, not a transcript\"));\n}","typeGuard":null,"tryCatchPattern":"// Cheap catch with rephrase guidance.\nmatch manager.queue_agent_mail(request).await {\n    Ok(resp) => Ok(resp),\n    Err(e) if e.to_string().contains(\"bounded handoff summary\") => {\n        Err(anyhow::anyhow!(\"agent mail summary rejected: rewrite as a short prose handoff\"))\n    }\n    Err(e) => Err(e),\n}","preventionTips":["Summaries are for goal/state/next-action prose, <= 2048 bytes","Put raw context in evidence references, not the summary body","Avoid lines that begin with 'assistant:'/'system:'/'tool:' - the detector is line-prefix based"],"tags":["agent-mail","input-validation","content-policy","handoff"],"backgroundTag":"payload-rejected-by-validation","analyzedSha":"0c42157ee52f9d55af2b506d71b46249910f77d3","analyzedAt":"2026-08-20T21:50:45.477Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}