diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index 6514fca7..ba8f7dd5 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -38,7 +38,7 @@ Tools (hyperhive surface): - `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel any question, reminder, or approval in the swarm. `kind` is `"question"` (bypasses the owner check used on sub-agents → hive-wide cleanup when an agent is offline / can't withdraw its own thread), `"reminder"` (same bypass), or `"approval"` (manager-only path → withdraws a pending approval YOU submitted that got superseded before the operator acted on it; the row resolves as `cancelled` and disappears from the operator's pending pane). - `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your *own* inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached. -- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Pass an empty string to clear. Persists across harness restarts. +- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Single line, ≤200 chars — the dashboard renders this as a short chip, so longer multi-line text is rejected. Pass an empty string to clear. Persists across harness restarts. - `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, `role` (`agent` / `manager`), current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale. - `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args. diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index a46a456d..f0a419ad 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -230,6 +230,12 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> } } AgentRequest::SetStatus { text } => { + // #720: cap length + reject multi-line so a confused caller + // can't dump a multi-paragraph session report into the + // dashboard chip. + if let Err(message) = crate::limits::check_status_text(text) { + return AgentResponse::Err { message }; + } let path = crate::coordinator::Coordinator::agent_notes_dir(agent) .join("hyperhive-status"); let result = if text.trim().is_empty() { diff --git a/hive-c0re/src/limits.rs b/hive-c0re/src/limits.rs index a1e3a173..e650971f 100644 --- a/hive-c0re/src/limits.rs +++ b/hive-c0re/src/limits.rs @@ -41,6 +41,56 @@ pub fn check_size(label: &str, body: &str) -> Result<(), String> { } } +/// 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::*; @@ -63,4 +113,62 @@ mod tests { 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()); + } } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index d867740d..9843ca63 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -502,6 +502,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp } } ManagerRequest::SetStatus { text } => { + // #720: cap length + reject multi-line so a confused caller + // can't dump a multi-paragraph session report into the + // dashboard chip. + if let Err(message) = crate::limits::check_status_text(text) { + return ManagerResponse::Err { message }; + } let path = Coordinator::agent_notes_dir(MANAGER_AGENT).join("hyperhive-status"); let result = if text.trim().is_empty() { std::fs::remove_file(&path).or_else(|e| {