Part of the docs-migration chore (issue #708). Remove GitHub issue numbers from inline comments, option descriptions, and rustdoc — these are contextless noise for anyone reading the code without access to the original discussions. Replace with prose that captures the same rationale directly. No functional change. Build still clean (cargo check passes).
173 lines
6.9 KiB
Rust
173 lines
6.9 KiB
Rust
//! 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/<you>/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. 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. Multi-line session reports should go to a state file.
|
|
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/<you>/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/<you>/state/<file>` 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());
|
|
}
|
|
}
|