From 0999cffdaf37ba5840be0dcfe312e6d335b57955 Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 28 Oct 2026 21:10:37 +0100 Subject: [PATCH] subagent headers: leave out a model or effort a header cannot hold The values come from the agent's start request. One with a line break made async-nats panic inside the publish task, which would have stopped every subagent's rows. They are now trimmed, and skipped when empty, over 64 characters, or not a valid header value. --- hive-subagent-mcp/src/swarm_term.rs | 47 +++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/hive-subagent-mcp/src/swarm_term.rs b/hive-subagent-mcp/src/swarm_term.rs index 667d7d13..0d2aeed1 100644 --- a/hive-subagent-mcp/src/swarm_term.rs +++ b/hive-subagent-mcp/src/swarm_term.rs @@ -84,19 +84,36 @@ pub struct RunInfo { } impl RunInfo { - /// The headers for one row: only the values there are. + /// The headers for one row: only the values there are, and only ones a + /// header can hold. The values come from the agent's start request, so one + /// with a line break (which `async_nats` panics on) or an absurd length is + /// left out rather than published. fn headers(&self) -> async_nats::HeaderMap { let mut headers = async_nats::HeaderMap::new(); - if let Some(model) = self.model.as_deref().filter(|v| !v.is_empty()) { + if let Some(model) = header_value(self.model.as_deref()) { headers.insert(subagent_term::HEADER_MODEL, model); } - if let Some(effort) = self.effort.as_deref().filter(|v| !v.is_empty()) { + if let Some(effort) = header_value(self.effort.as_deref()) { headers.insert(subagent_term::HEADER_EFFORT, effort); } headers } } +/// The longest model or effort name published. Real ones are a few +/// characters; the value rides on every row and is shown in the UI. +const MAX_VALUE_LEN: usize = 64; + +/// `value` trimmed, as a header value, or `None` when it is empty, too long, or +/// not one a header can carry. +fn header_value(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() || value.len() > MAX_VALUE_LEN { + return None; + } + value.parse().ok() +} + struct Line { subagent: String, info: Arc, @@ -486,6 +503,30 @@ mod tests { assert!(info.headers().is_empty()); } + /// The values are the caller's. A line break would panic `async_nats`, in + /// the task that publishes every subagent's rows. + #[test] + fn a_value_a_header_cannot_hold_is_left_out_and_never_panics() { + for bad in ["a\nb", "high\r\nX: y", &"x".repeat(MAX_VALUE_LEN + 1)] { + let info = RunInfo { + model: Some(bad.to_owned()), + effort: Some(bad.to_owned()), + }; + assert!(info.headers().is_empty(), "{bad:?}"); + } + let padded = RunInfo { + model: Some(" opus\n".to_owned()), + effort: None, + }; + assert_eq!( + padded + .headers() + .get(subagent_term::HEADER_MODEL) + .map(ToString::to_string), + Some("opus".to_owned()) + ); + } + #[test] fn every_kind_of_line_renders_as_stamped_rows() { let mut ctx = ClassifyCtx::default();