Watch
0
0
Fork
You've already forked hyperhive
0

subagents publish their model and effort as headers on every row, and the swarm shows them

The subagent daemon stamps Swarm-Model and Swarm-Effort on each row it
publishes. The list endpoint reads them off the newest row it already fetches,
so a long-running subagent keeps them past the stream's 24 hour window, and the
terminal page's menu and the agent panel's list show them beside the name.
This commit is contained in:
iris 2026-10-28 20:54:16 +01:00 • committed by mara
commit da5fcbfd9b
2 changed files with 219 additions and 25 deletions

View file

@ -1814,6 +1814,9 @@ fn continue_on_runtime(
struct LivenessSink {
state: Arc<State>,
name: String,
/// The model and effort this run was started with, stamped on every row
/// it publishes.
info: Arc<crate::swarm_term::RunInfo>,
/// Where to report the first non-terminal event, on a `resume` whose
/// caller is still waiting to hear whether it landed. `None` on a
/// `start`, which has nobody waiting.
@ -1845,11 +1848,20 @@ impl LivenessSink {
/// one to take it.
fn publish(&self, line: impl FnOnce() -> Output) {
if let Some(term) = &self.state.term {
term.offer(&self.name, line());
term.offer(&self.name, &self.info, line());
}
}
}
/// What a run's rows say about how it was started: its `config`'s model and
/// effort, which the swarm shows beside the subagent's name.
fn run_info(config: &Config) -> Arc<crate::swarm_term::RunInfo> {
Arc::new(crate::swarm_term::RunInfo {
model: config.model.clone(),
effort: config.effort.clone(),
})
}
/// Spawn the child (synchronous — `Claude::spawn` returns with a real pid
/// the instant the process exists), track it in `running`, and hand the
/// actual turn off to a background task so nobody blocks on the whole turn.
@ -1911,6 +1923,7 @@ fn spawn_and_track(
None
};
let info = run_info(config);
let config = config.clone();
let state = Arc::clone(state);
let task_name = name.to_owned();
@ -1922,6 +1935,7 @@ fn spawn_and_track(
let sink = LivenessSink {
state: Arc::clone(&state),
name: task_name.clone(),
info: Arc::clone(&info),
verdict: verdict.clone(),
};
let end = classify_end(running.wait(&prompt, &sink).await, searched.as_deref());
@ -2025,6 +2039,7 @@ fn spawn_and_track_acp(
state.clear_kill(name);
state.note_event(name);
let info = run_info(&config);
let state = Arc::clone(state);
let task_name = name.to_owned();
tokio::spawn(async move {
@ -2033,6 +2048,7 @@ fn spawn_and_track_acp(
let sink = LivenessSink {
state: Arc::clone(&state),
name: task_name.clone(),
info: Arc::clone(&info),
verdict: verdict.clone(),
};
let end = match runtime.run(&config, &prompt, &sink).await {
@ -3455,6 +3471,7 @@ mod tests {
let sink = LivenessSink {
state: Arc::clone(&state),
name: "n".to_owned(),
info: Arc::default(),
verdict: None,
};
(state, sink)
@ -3745,6 +3762,7 @@ mod tests {
let sink = LivenessSink {
state: Arc::new(State::new(PathBuf::from("/dev/null"), signal_url())),
name: "n".to_owned(),
info: Arc::default(),
verdict: Some(Arc::new(Mutex::new(Some(tx)))),
};
(rx, sink)

View file

@ -74,8 +74,32 @@ pub enum Output {
Stderr(String),
}
/// The model and effort a subagent was started with, published as headers on
/// each of its rows (`swarm_wire::subagent_term`). `None` is the harness's
/// default, which has no name to publish.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunInfo {
pub model: Option<String>,
pub effort: Option<String>,
}
impl RunInfo {
/// The headers for one row: only the values there are.
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()) {
headers.insert(subagent_term::HEADER_MODEL, model);
}
if let Some(effort) = self.effort.as_deref().filter(|v| !v.is_empty()) {
headers.insert(subagent_term::HEADER_EFFORT, effort);
}
headers
}
}
struct Line {
subagent: String,
info: Arc<RunInfo>,
output: Output,
/// Unix seconds when the sink saw the line.
ts: i64,
@ -131,14 +155,16 @@ impl Publisher {
(publisher, rx)
}
/// Queue `output` from `subagent` for publishing. Never blocks: a full
/// queue drops the line and counts it.
pub fn offer(&self, subagent: &str, output: Output) {
/// Queue `output` from `subagent` for publishing, with the `info` its rows
/// carry as headers. Never blocks: a full queue drops the line and counts
/// it.
pub fn offer(&self, subagent: &str, info: &Arc<RunInfo>, output: Output) {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
let line = Line {
subagent: subagent.to_owned(),
info: Arc::clone(info),
output,
ts,
};
@ -296,8 +322,9 @@ async fn run(mut rx: mpsc::Receiver<Line>, target: Target, dropped: Arc<AtomicU6
continue;
};
let subject = target.subject(&line.subagent);
let headers = line.info.headers();
for row in rows {
publish(&client, &subject, row).await;
publish(&client, &subject, &headers, row).await;
}
}
}
@ -314,7 +341,12 @@ fn classify(output: &Output, ts: i64, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
}
/// Offer one row. Every failure is terminal for that row and for nothing else.
async fn publish(client: &async_nats::Client, subject: &str, msg: TermMsg) {
async fn publish(
client: &async_nats::Client,
subject: &str,
headers: &async_nats::HeaderMap,
msg: TermMsg,
) {
// Unconnected, the client buffers rather than fails, and reports the
// library's default payload limit rather than the server's.
if let Err(e) = swarm_queue_client::ensure_connected(client) {
@ -338,7 +370,10 @@ async fn publish(client: &async_nats::Client, subject: &str, msg: TermMsg) {
return;
}
};
if let Err(e) = client.publish(subject.to_owned(), payload.into()).await {
let sent = client
.publish_with_headers(subject.to_owned(), headers.clone(), payload.into())
.await;
if let Err(e) = sent {
tracing::warn!(error = %e, "subagent terminal: publish failed, row dropped");
}
}
@ -412,11 +447,45 @@ mod tests {
#[test]
fn a_full_queue_drops_and_counts_rather_than_blocks() {
let (publisher, _rx) = Publisher::channel(1);
publisher.offer("scout", Output::Stdout("one".to_owned()));
publisher.offer("scout", Output::Stdout("two".to_owned()));
let info = Arc::new(RunInfo::default());
publisher.offer("scout", &info, Output::Stdout("one".to_owned()));
publisher.offer("scout", &info, Output::Stdout("two".to_owned()));
assert_eq!(publisher.dropped.load(Ordering::Relaxed), 1);
}
/// The list endpoint reads these two headers off a subagent's newest row.
#[test]
fn a_row_carries_the_model_and_effort_the_subagent_runs() {
let info = RunInfo {
model: Some("opus".to_owned()),
effort: Some("high".to_owned()),
};
let headers = info.headers();
assert_eq!(
headers
.get(subagent_term::HEADER_MODEL)
.map(ToString::to_string),
Some("opus".to_owned())
);
assert_eq!(
headers
.get(subagent_term::HEADER_EFFORT)
.map(ToString::to_string),
Some("high".to_owned())
);
}
/// A subagent on the default model names none, and an empty header would
/// read as a model called "".
#[test]
fn a_value_that_is_not_set_is_not_published() {
let info = RunInfo {
model: None,
effort: Some(String::new()),
};
assert!(info.headers().is_empty());
}
#[test]
fn every_kind_of_line_renders_as_stamped_rows() {
let mut ctx = ClassifyCtx::default();