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:
parent
ae9c3444bc
commit
da5fcbfd9b
1 changed files with 219 additions and 25 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue