//! Wire-protocol size limits shared across the agent + manager //! sockets. Caps on inline message bodies stop a single chatty agent //! (or a misbehaving extra-MCP server) from flooding the broker //! sqlite with megabyte-sized rows that then bloat every recipient's //! wake-prompt context. Anything genuinely larger should be written //! to a state file and the path sent as the body. //! //! Reminders get a separate auto-file escape hatch (see //! `agent_server::handle_remind`) so callers don't have to think //! about it — oversized reminder bodies get persisted to disk //! transparently and the inbox sees a pointer. /// Per-message body cap. Applies to `send`, `ask` question text, /// `answer` body, and the stored inline form of a reminder. 4 KiB /// catches the bulk of conversational overflow (status reports, /// bullet-list summaries, short proposals) while staying small /// enough that a backed-up inbox of ~10 unread messages only adds /// ~40 KiB to the recipient's wake-prompt context. Genuinely /// long-form artifacts (audit reports, full diffs, transcripts) /// still belong in a state file — the error message on overflow /// points callers at that escape hatch. pub const MESSAGE_MAX_BYTES: usize = 4096; /// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a /// caller-ready error string (caller wraps in /// `AgentResponse::Err`/`ManagerResponse::Err`) on failure. /// /// `label` shows up in the error message verbatim — pass a short /// noun like `"send"`, `"question"`, `"broadcast"` so the model can /// tell which call got rejected. pub fn check_size(label: &str, body: &str) -> Result<(), String> { if body.len() > MESSAGE_MAX_BYTES { Err(format!( "{label} body too long ({} bytes, max {MESSAGE_MAX_BYTES}); write the \ payload to a file under your `/agents//state/` dir and send the \ path as the body instead", body.len() )) } else { Ok(()) } } /// Per-status soft cap. `set_status` renders as a short chip on the /// dashboard agent card — the front-end truncates long strings to /// keep the row layout intact, so a multi-paragraph "session report" /// is wasted bytes that just bloat the rescan emit + container view /// payload. Cap at 200 chars to fit the chip plus a little /// descriptive padding without forcing the operator to read a /// scrolling chunk. pub const STATUS_MAX_CHARS: usize = 200; /// Validate a `set_status` payload (#720). Single-line + bounded so /// callers can't dump multi-paragraph session reports into the /// dashboard chip. Whitespace trim is done by the caller before the /// store-to-disk step — we run validation on the trimmed form so /// surrounding whitespace doesn't push a borderline-legal status /// past the cap. /// /// Empty / all-whitespace input is accepted: the call site treats /// that as "clear the status" and removes the on-disk sentinel. Tests /// + caller cover both directions. /// /// Returns a caller-ready error string suitable for surfacing in the /// `*Response::Err` shape. pub fn check_status_text(text: &str) -> Result<(), String> { let trimmed = text.trim(); if trimmed.is_empty() { // Empty = clear-status sentinel; nothing to validate. return Ok(()); } // Newline / carriage-return: status is a single-line chip on the // dashboard. A multi-line argus session report is the canonical // failure mode from #720. if trimmed.contains('\n') || trimmed.contains('\r') { return Err( "set_status text must be a single line — write multi-line context to \ a file under your `/agents//state/` dir and reference that path \ from the chip instead" .to_owned(), ); } let len = trimmed.chars().count(); if len > STATUS_MAX_CHARS { return Err(format!( "set_status text too long ({len} chars, max {STATUS_MAX_CHARS}); the \ dashboard chip truncates anything longer, so trim to a short summary \ and write the detail to `/agents//state/` instead" )); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn accepts_short_body() { assert!(check_size("send", "hello").is_ok()); assert!(check_size("send", &"x".repeat(MESSAGE_MAX_BYTES)).is_ok()); } #[test] fn rejects_oversize_body() { let err = check_size("send", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err(); assert!(err.contains("send body too long")); assert!(err.contains(&format!("max {MESSAGE_MAX_BYTES}"))); } #[test] fn label_threads_through() { let err = check_size("question", &"x".repeat(MESSAGE_MAX_BYTES + 1)).unwrap_err(); assert!(err.starts_with("question body too long")); } #[test] fn check_status_accepts_short_single_line() { assert!(check_status_text("idle").is_ok()); assert!(check_status_text("processing matrix messages").is_ok()); // Boundary: exactly STATUS_MAX_CHARS chars trimmed is still // accepted; one more rejects. let max = "a".repeat(STATUS_MAX_CHARS); assert!(check_status_text(&max).is_ok()); } #[test] fn check_status_accepts_empty_and_whitespace() { // Empty + whitespace-only are the "clear status" sentinel and // bypass the rest of the checks. assert!(check_status_text("").is_ok()); assert!(check_status_text(" ").is_ok()); assert!(check_status_text("\n\t ").is_ok()); } #[test] fn check_status_rejects_multi_line() { let err = check_status_text("line one\nline two").unwrap_err(); assert!(err.contains("single line"), "err = {err}"); // Carriage return alone also rejects (windows linebreak / CR-only). assert!(check_status_text("a\rb").is_err()); } #[test] fn check_status_rejects_oversize() { let too_long = "a".repeat(STATUS_MAX_CHARS + 1); let err = check_status_text(&too_long).unwrap_err(); assert!(err.contains("too long"), "err = {err}"); assert!(err.contains(&format!("max {STATUS_MAX_CHARS}"))); } #[test] fn check_status_counts_chars_not_bytes() { // Multi-byte chars (emoji, accented letters) count once each // per char — chars().count() not byte len. STATUS_MAX_CHARS // worth of 4-byte chars is still legal. let emoji = "💜".repeat(STATUS_MAX_CHARS); assert!( check_status_text(&emoji).is_ok(), "{STATUS_MAX_CHARS} emoji should fit" ); let too_many = "💜".repeat(STATUS_MAX_CHARS + 1); assert!(check_status_text(&too_many).is_err()); } #[test] fn check_status_validates_post_trim() { // Leading/trailing whitespace is trimmed before the length // check — a borderline-legal payload with spaces around it // still passes. let padded = format!(" {} ", "a".repeat(STATUS_MAX_CHARS)); assert!(check_status_text(&padded).is_ok()); } }